@geraldmaron/construct 1.0.6 → 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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * lib/doctor/watchers/consistency.mjs — cross-surface drift watcher.
3
+ *
4
+ * Five drift checks bundled at one cadence:
5
+ * 1. Hook manifest ↔ files: every entry in platforms/claude/settings.template.json
6
+ * points to a real .mjs file in lib/hooks/ that parses.
7
+ * 2. MCP registration ↔ impl: every `name === '<tool>'` branch in the MCP
8
+ * dispatch resolves to an imported function, and every exported tool
9
+ * function in lib/mcp/tools/*.mjs is wired into the dispatch.
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
+ * 4. Persona promptFiles: every registry.personas[].promptFile and
13
+ * registry.agents[].promptFile exists on disk.
14
+ * 5. Contracts ↔ schemas: specialists/contracts.json passes validateContractsFile
15
+ * (cross-file refs, producer/consumer names, schema paths).
16
+ *
17
+ * Each violation produces an audit record. Blocking severity escalates to a
18
+ * role intake; advisory severity records only. Tick is cheap — reads JSON +
19
+ * directory listings, no network.
20
+ */
21
+
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
+ import { join, dirname, resolve } from 'node:path';
24
+ import os from 'node:os';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import { record } from '../audit.mjs';
28
+ import { escalate } from '../escalate.mjs';
29
+
30
+ export const name = 'consistency';
31
+ export const intervalMs = 15 * 60 * 1000;
32
+
33
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
34
+
35
+ export async function tick() {
36
+ const results = await runAllChecks({ repoRoot: REPO_ROOT });
37
+ const actions = [];
38
+ const escalations = [];
39
+
40
+ for (const finding of results.findings) {
41
+ record({
42
+ kind: finding.severity === 'blocking' ? 'escalation' : 'action',
43
+ watcher: name,
44
+ action: finding.category,
45
+ summary: finding.summary,
46
+ context: { details: finding.details },
47
+ });
48
+ actions.push({ type: finding.category, target: finding.target || null });
49
+ if (finding.severity === 'blocking') {
50
+ escalate({
51
+ watcher: name,
52
+ event: `consistency.${finding.category}`,
53
+ severity: 'high',
54
+ summary: finding.summary,
55
+ context: { details: finding.details },
56
+ });
57
+ escalations.push({ event: `consistency.${finding.category}`, severity: 'high' });
58
+ }
59
+ }
60
+
61
+ return { actions, escalations };
62
+ }
63
+
64
+ /**
65
+ * Run all six consistency checks. Returns { findings: [], passed: [] }.
66
+ * Pure function — no audit/escalate side effects. Surfaced separately so the
67
+ * `construct doctor consistency` CLI and the release-gate functional test can
68
+ * call it without spinning up the daemon.
69
+ */
70
+ export async function runAllChecks({ repoRoot = REPO_ROOT } = {}) {
71
+ const findings = [];
72
+ const passed = [];
73
+
74
+ const hooks = checkHooksDrift({ repoRoot });
75
+ collect(hooks, findings, passed, 'hooks-drift');
76
+
77
+ const mcp = checkMcpDrift({ repoRoot });
78
+ collect(mcp, findings, passed, 'mcp-drift');
79
+
80
+ const roles = checkRolesDrift({ repoRoot });
81
+ collect(roles, findings, passed, 'roles-drift');
82
+
83
+ const prompts = checkPersonaPrompts({ repoRoot });
84
+ collect(prompts, findings, passed, 'prompt-files');
85
+
86
+ const contracts = await checkContractsDrift({ repoRoot });
87
+ collect(contracts, findings, passed, 'contracts-drift');
88
+
89
+ const hosts = checkHostSurfaces({ repoRoot });
90
+ collect(hosts, findings, passed, 'host-surfaces');
91
+
92
+ return { findings, passed };
93
+ }
94
+
95
+ function collect(result, findings, passed, defaultCategory) {
96
+ if (result.violations.length === 0) {
97
+ passed.push({ category: defaultCategory, summary: result.summary });
98
+ return;
99
+ }
100
+ for (const v of result.violations) {
101
+ findings.push({
102
+ category: v.category || defaultCategory,
103
+ severity: v.severity || 'warning',
104
+ summary: v.summary,
105
+ target: v.target || null,
106
+ details: v.details || null,
107
+ });
108
+ }
109
+ }
110
+
111
+ // ── Check 1: hook manifest ↔ files ─────────────────────────────────────────
112
+
113
+ function checkHooksDrift({ repoRoot }) {
114
+ const violations = [];
115
+ const settingsPath = join(repoRoot, 'platforms', 'claude', 'settings.template.json');
116
+ const hooksDir = join(repoRoot, 'lib', 'hooks');
117
+
118
+ if (!existsSync(settingsPath)) {
119
+ return {
120
+ summary: 'settings template missing',
121
+ violations: [{ severity: 'blocking', summary: `settings template missing: ${settingsPath}` }],
122
+ };
123
+ }
124
+
125
+ let settings;
126
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
127
+ catch (err) {
128
+ return {
129
+ summary: 'settings template parse error',
130
+ violations: [{ severity: 'blocking', summary: `settings template parse error: ${err.message}` }],
131
+ };
132
+ }
133
+
134
+ const referenced = new Set();
135
+ collectHookCommands(settings.hooks, referenced);
136
+
137
+ for (const hookPath of referenced) {
138
+ const resolvedPath = resolveHookPath(hookPath, hooksDir, repoRoot);
139
+ if (!resolvedPath || !existsSync(resolvedPath)) {
140
+ violations.push({
141
+ category: 'hooks-drift',
142
+ severity: 'blocking',
143
+ target: hookPath,
144
+ summary: `hook referenced by settings template does not exist on disk: ${hookPath}`,
145
+ });
146
+ }
147
+ }
148
+
149
+ return { summary: `hooks: ${referenced.size} referenced, ${violations.length} missing`, violations };
150
+ }
151
+
152
+ function collectHookCommands(node, out) {
153
+ if (!node) return;
154
+ if (Array.isArray(node)) { node.forEach((n) => collectHookCommands(n, out)); return; }
155
+ if (typeof node !== 'object') return;
156
+ if (typeof node.command === 'string' && /\.mjs/.test(node.command)) {
157
+ const match = node.command.match(/([\w./-]+\.mjs)/);
158
+ if (match) out.add(match[1]);
159
+ }
160
+ for (const v of Object.values(node)) collectHookCommands(v, out);
161
+ }
162
+
163
+ function resolveHookPath(reference, hooksDir, repoRoot) {
164
+ if (reference.startsWith('/')) return reference;
165
+ if (reference.startsWith('lib/hooks/')) return join(repoRoot, reference);
166
+ const bare = reference.split('/').pop();
167
+ return join(hooksDir, bare);
168
+ }
169
+
170
+ // ── Check 2: MCP registration ↔ impl ───────────────────────────────────────
171
+
172
+ function checkMcpDrift({ repoRoot }) {
173
+ const violations = [];
174
+ const serverPath = join(repoRoot, 'lib', 'mcp', 'server.mjs');
175
+ const toolsDir = join(repoRoot, 'lib', 'mcp', 'tools');
176
+
177
+ if (!existsSync(serverPath) || !existsSync(toolsDir)) {
178
+ return { summary: 'mcp surface missing', violations: [] };
179
+ }
180
+
181
+ const serverSource = readFileSync(serverPath, 'utf8');
182
+ const dispatchedTools = new Set();
183
+ for (const m of serverSource.matchAll(/name === ['"]([a-z][a-z0-9_]*)['"]/g)) {
184
+ dispatchedTools.add(m[1]);
185
+ }
186
+
187
+ const exportedTools = new Set();
188
+ const entries = readdirSync(toolsDir, { withFileTypes: true });
189
+ for (const entry of entries) {
190
+ if (!entry.isFile() || !entry.name.endsWith('.mjs')) continue;
191
+ const src = readFileSync(join(toolsDir, entry.name), 'utf8');
192
+ for (const m of src.matchAll(/export (?:async )?function (\w+)/g)) {
193
+ exportedTools.add(camelToSnake(m[1]));
194
+ }
195
+ }
196
+
197
+ for (const tool of exportedTools) {
198
+ if (KNOWN_NON_DISPATCH_TOOLS.has(tool)) continue;
199
+ if (!dispatchedTools.has(tool)) {
200
+ violations.push({
201
+ category: 'mcp-drift',
202
+ severity: 'warning',
203
+ target: tool,
204
+ summary: `MCP tool exported but not dispatched: ${tool}`,
205
+ });
206
+ }
207
+ }
208
+
209
+ return { summary: `mcp: ${dispatchedTools.size} dispatched, ${exportedTools.size} exported, ${violations.length} drift`, violations };
210
+ }
211
+
212
+ const KNOWN_NON_DISPATCH_TOOLS = new Set([
213
+ // Internal helpers exported for tests but never wired through MCP.
214
+ 'workflow_status_bound',
215
+ 'create_needs_main_input_packet',
216
+ ]);
217
+
218
+ function camelToSnake(s) {
219
+ return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
220
+ }
221
+
222
+ // ── Check 3: roles ↔ registry ──────────────────────────────────────────────
223
+
224
+ function checkRolesDrift({ repoRoot }) {
225
+ const violations = [];
226
+ const manifestsPath = join(repoRoot, 'specialists', 'role-manifests.json');
227
+ const registryPath = join(repoRoot, 'specialists', 'registry.json');
228
+
229
+ if (!existsSync(manifestsPath) || !existsSync(registryPath)) {
230
+ return { summary: 'role manifests or registry missing', violations: [] };
231
+ }
232
+
233
+ const manifests = JSON.parse(readFileSync(manifestsPath, 'utf8'));
234
+ const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
235
+
236
+ const known = new Set();
237
+ for (const s of registry.specialists || []) {
238
+ if (s?.name) { known.add(s.name); known.add(`cx-${s.name}`); }
239
+ }
240
+ if (registry.orchestrator?.name) {
241
+ known.add(registry.orchestrator.name);
242
+ known.add(`cx-${registry.orchestrator.name}`);
243
+ }
244
+
245
+ const personaKeys = Object.keys(manifests.personas || manifests || {});
246
+ for (const key of personaKeys) {
247
+ if (key === 'version' || key === 'description' || key === 'schemaVersion') continue;
248
+ if (!known.has(key) && !known.has(`cx-${key}`)) {
249
+ violations.push({
250
+ category: 'roles-drift',
251
+ severity: 'warning',
252
+ target: key,
253
+ summary: `role-manifests persona '${key}' does not resolve to a registry persona/agent`,
254
+ });
255
+ }
256
+ }
257
+
258
+ return { summary: `roles: ${personaKeys.length} manifest keys, ${violations.length} drift`, violations };
259
+ }
260
+
261
+ // ── Check 4: persona promptFile existence ──────────────────────────────────
262
+
263
+ function checkPersonaPrompts({ repoRoot }) {
264
+ const violations = [];
265
+ const registryPath = join(repoRoot, 'specialists', 'registry.json');
266
+ if (!existsSync(registryPath)) return { summary: 'registry missing', violations: [] };
267
+
268
+ const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
269
+ let checked = 0;
270
+
271
+ for (const list of [
272
+ registry.orchestrator ? [registry.orchestrator] : [],
273
+ registry.specialists || [],
274
+ ]) {
275
+ for (const entry of list) {
276
+ const ref = entry?.promptFile;
277
+ if (!ref) continue;
278
+ checked++;
279
+ const promptPath = ref.startsWith('/') ? ref : join(repoRoot, ref);
280
+ if (!existsSync(promptPath)) {
281
+ violations.push({
282
+ category: 'prompt-files',
283
+ severity: 'blocking',
284
+ target: ref,
285
+ summary: `registry entry '${entry.name}' references missing promptFile: ${ref}`,
286
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ return { summary: `prompt-files: ${checked} referenced, ${violations.length} missing`, violations };
292
+ }
293
+
294
+ // ── Check 5: contracts ↔ schemas ───────────────────────────────────────────
295
+
296
+ async function checkContractsDrift({ repoRoot }) {
297
+ const violations = [];
298
+ try {
299
+ const { validateContractsFile } = await import('../../contracts/validate.mjs');
300
+ const result = validateContractsFile({ repoRoot });
301
+ if (!result.ok) {
302
+ for (const err of result.errors) {
303
+ violations.push({
304
+ category: 'contracts-drift',
305
+ severity: 'warning',
306
+ summary: err,
307
+ });
308
+ }
309
+ }
310
+ } catch (err) {
311
+ violations.push({
312
+ category: 'contracts-drift',
313
+ severity: 'warning',
314
+ summary: `contract validator unavailable: ${err.message}`,
315
+ });
316
+ }
317
+ return { summary: `contracts: ${violations.length} drift`, violations };
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
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * lib/doctor/watchers/mcp-protocol.mjs — MCP handshake health probe.
3
+ *
4
+ * Sweeps every MCP server configured in the active editor configs (Claude
5
+ * Code, OpenCode) and performs a real MCP handshake against each: send
6
+ * `initialize`, parse the response, send `tools/list`, report tool count.
7
+ * cass-memory v0.2.x (the original cause of construct-1qt) rejects the
8
+ * handshake outright; this watcher flags servers that fail the handshake so
9
+ * the dashboard surfaces a protocol problem rather than a port-up false
10
+ * positive.
11
+ *
12
+ * Severity defaults to warning. Set `CONSTRUCT_MCP_PROTOCOL_BLOCKING=1` to
13
+ * escalate via `service.down` when a configured server fails the handshake.
14
+ *
15
+ * Tick: 10 min. Cheap — most checks are skipped when no editor configs exist.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { record } from '../audit.mjs';
24
+ import { escalate } from '../escalate.mjs';
25
+
26
+ export const name = 'mcp-protocol';
27
+ export const intervalMs = 10 * 60 * 1000;
28
+
29
+ const PROBE_TIMEOUT_MS = 5000;
30
+ const HANDSHAKE_REQUEST_ID = 1;
31
+
32
+ function readJson(file) {
33
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
34
+ }
35
+
36
+ export function collectMcpServers({ home = homedir() } = {}) {
37
+ const servers = [];
38
+ const claudePath = join(home, '.claude', 'settings.json');
39
+ if (existsSync(claudePath)) {
40
+ const cfg = readJson(claudePath);
41
+ for (const [id, entry] of Object.entries(cfg?.mcpServers ?? {})) {
42
+ servers.push({ host: 'claude', id, entry });
43
+ }
44
+ }
45
+ const opencodePath = join(home, '.config', 'opencode', 'opencode.json');
46
+ if (existsSync(opencodePath)) {
47
+ const cfg = readJson(opencodePath);
48
+ for (const [id, entry] of Object.entries(cfg?.mcp ?? {})) {
49
+ servers.push({ host: 'opencode', id, entry });
50
+ }
51
+ }
52
+ return servers;
53
+ }
54
+
55
+ function classifyEntry(entry) {
56
+ if (!entry || typeof entry !== 'object') return { transport: 'unknown' };
57
+ if (entry.type === 'http' || entry.type === 'remote') {
58
+ return { transport: 'http', url: entry.url, headers: entry.headers ?? {} };
59
+ }
60
+ if (entry.type === 'local') {
61
+ const cmd = Array.isArray(entry.command) ? entry.command : [];
62
+ return { transport: 'stdio', command: cmd[0], args: cmd.slice(1), env: entry.environment ?? {} };
63
+ }
64
+ if (entry.command) {
65
+ return { transport: 'stdio', command: entry.command, args: entry.args ?? [], env: entry.env ?? {} };
66
+ }
67
+ return { transport: 'unknown' };
68
+ }
69
+
70
+ async function probeHttp({ url, headers }) {
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
73
+ try {
74
+ const body = {
75
+ jsonrpc: '2.0',
76
+ id: HANDSHAKE_REQUEST_ID,
77
+ method: 'initialize',
78
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'construct-doctor', version: '1' } },
79
+ };
80
+ const res = await fetch(url, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
83
+ body: JSON.stringify(body),
84
+ signal: controller.signal,
85
+ });
86
+ if (!res.ok) {
87
+ return { ok: false, reason: `HTTP ${res.status} on initialize` };
88
+ }
89
+ const text = await res.text();
90
+ let parsed;
91
+ try { parsed = JSON.parse(text); } catch {
92
+ return { ok: false, reason: 'initialize response was not JSON' };
93
+ }
94
+ if (parsed?.error) {
95
+ return { ok: false, reason: `initialize returned error: ${parsed.error.message ?? parsed.error.code}` };
96
+ }
97
+ if (/Unsupported method/i.test(text)) {
98
+ return { ok: false, reason: 'backend rejects initialize (Unsupported method)' };
99
+ }
100
+ return { ok: true, protocolVersion: parsed?.result?.protocolVersion };
101
+ } catch (err) {
102
+ const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || String(err));
103
+ return { ok: false, reason };
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ function spawnStdioServer({ command, args, env }) {
110
+ const child = spawn(command, args ?? [], {
111
+ env: { ...process.env, ...(env ?? {}) },
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ });
114
+ const frames = [];
115
+ let buffer = '';
116
+ child.stdout.setEncoding('utf8');
117
+ child.stdout.on('data', (chunk) => {
118
+ buffer += chunk;
119
+ let idx;
120
+ while ((idx = buffer.indexOf('\n')) >= 0) {
121
+ const raw = buffer.slice(0, idx).trim();
122
+ buffer = buffer.slice(idx + 1);
123
+ if (!raw) continue;
124
+ try { frames.push(JSON.parse(raw)); } catch { /* skip junk */ }
125
+ }
126
+ });
127
+ child.stderr.on('data', () => { /* swallow */ });
128
+ return { child, frames };
129
+ }
130
+
131
+ async function waitFor(frames, predicate, deadline) {
132
+ while (Date.now() < deadline) {
133
+ const hit = frames.find(predicate);
134
+ if (hit) return hit;
135
+ await new Promise((r) => setTimeout(r, 25));
136
+ }
137
+ return null;
138
+ }
139
+
140
+ async function probeStdio(spec) {
141
+ if (!spec.command) return { ok: false, reason: 'stdio entry missing command' };
142
+ let session;
143
+ try {
144
+ session = spawnStdioServer(spec);
145
+ } catch (err) {
146
+ return { ok: false, reason: `spawn failed: ${err?.message || err}` };
147
+ }
148
+ const { child, frames } = session;
149
+ const deadline = Date.now() + PROBE_TIMEOUT_MS;
150
+
151
+ try {
152
+ child.stdin.write(JSON.stringify({
153
+ jsonrpc: '2.0',
154
+ id: HANDSHAKE_REQUEST_ID,
155
+ method: 'initialize',
156
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'construct-doctor', version: '1' } },
157
+ }) + '\n');
158
+
159
+ const initFrame = await waitFor(frames, (f) => f.id === HANDSHAKE_REQUEST_ID, deadline);
160
+ if (!initFrame) return { ok: false, reason: 'no initialize response within timeout' };
161
+ if (initFrame.error) return { ok: false, reason: `initialize error: ${initFrame.error.message ?? initFrame.error.code}` };
162
+
163
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
164
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
165
+
166
+ const toolsFrame = await waitFor(frames, (f) => f.id === 2, deadline);
167
+ if (!toolsFrame) return { ok: false, reason: 'no tools/list response within timeout' };
168
+ if (toolsFrame.error) return { ok: false, reason: `tools/list error: ${toolsFrame.error.message ?? toolsFrame.error.code}` };
169
+
170
+ const tools = toolsFrame.result?.tools ?? [];
171
+ return {
172
+ ok: true,
173
+ protocolVersion: initFrame.result?.protocolVersion,
174
+ toolCount: Array.isArray(tools) ? tools.length : 0,
175
+ };
176
+ } finally {
177
+ try { child.kill('SIGTERM'); } catch { /* already exited */ }
178
+ }
179
+ }
180
+
181
+ export async function probeServer({ host, id, entry }) {
182
+ const spec = classifyEntry(entry);
183
+ if (spec.transport === 'unknown') return { host, id, transport: 'unknown', ok: false, reason: 'unrecognized entry shape' };
184
+ const result = spec.transport === 'http' ? await probeHttp(spec) : await probeStdio(spec);
185
+ return { host, id, transport: spec.transport, ...result };
186
+ }
187
+
188
+ export async function tick() {
189
+ const actions = [];
190
+ const escalations = [];
191
+ const samples = [];
192
+
193
+ const servers = collectMcpServers();
194
+ for (const server of servers) {
195
+ const result = await probeServer(server);
196
+ samples.push(result);
197
+
198
+ if (result.ok) {
199
+ record({
200
+ kind: 'sample',
201
+ watcher: name,
202
+ target: `${result.host}/${result.id}`,
203
+ result: 'ok',
204
+ summary: `${result.host}/${result.id} MCP handshake ok (transport=${result.transport})`,
205
+ context: { protocolVersion: result.protocolVersion, toolCount: result.toolCount },
206
+ });
207
+ continue;
208
+ }
209
+
210
+ record({
211
+ kind: 'sample',
212
+ watcher: name,
213
+ target: `${result.host}/${result.id}`,
214
+ result: 'warn',
215
+ summary: `${result.host}/${result.id} MCP handshake failed: ${result.reason}`,
216
+ context: { transport: result.transport, reason: result.reason },
217
+ });
218
+ actions.push({ type: 'protocol-warning', target: `${result.host}/${result.id}`, reason: result.reason });
219
+
220
+ if (process.env.CONSTRUCT_MCP_PROTOCOL_BLOCKING === '1') {
221
+ const r = await escalate({
222
+ watcher: name,
223
+ eventType: 'service.down',
224
+ summary: `MCP handshake failed for ${result.host}/${result.id}: ${result.reason}`,
225
+ context: { host: result.host, id: result.id, transport: result.transport, reason: result.reason },
226
+ });
227
+ escalations.push({ eventType: 'service.down', target: `${result.host}/${result.id}`, result: r });
228
+ }
229
+ }
230
+
231
+ return { actions, escalations, notes: samples };
232
+ }