@geraldmaron/construct 1.0.3 → 1.0.5

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 (157) hide show
  1. package/README.md +51 -42
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +279 -4
  19. package/commands/understand/research.md +5 -3
  20. package/db/schema/004_recommendations.sql +46 -0
  21. package/db/schema/005_strategy.sql +21 -0
  22. package/lib/auto-docs.mjs +3 -4
  23. package/lib/beads-automation.mjs +16 -7
  24. package/lib/cli-commands.mjs +52 -2
  25. package/lib/comment-lint.mjs +7 -1
  26. package/lib/config/schema.mjs +3 -0
  27. package/lib/embed/conflict-detection.mjs +26 -9
  28. package/lib/embed/customer-profiles.mjs +37 -17
  29. package/lib/embed/daemon.mjs +10 -8
  30. package/lib/embed/recommendation-store.mjs +213 -14
  31. package/lib/embed/workspaces.mjs +53 -18
  32. package/lib/flavors/loader.mjs +136 -0
  33. package/lib/gates-audit.mjs +3 -3
  34. package/lib/health-check.mjs +1 -1
  35. package/lib/hooks/agent-tracker.mjs +22 -3
  36. package/lib/hooks/pre-compact.mjs +3 -0
  37. package/lib/hooks/pre-push-gate.mjs +14 -1
  38. package/lib/hooks/read-tracker.mjs +10 -101
  39. package/lib/hooks/session-optimize.mjs +3 -2
  40. package/lib/hooks/session-reflect.mjs +68 -0
  41. package/lib/host-capabilities.mjs +90 -1
  42. package/lib/init-unified.mjs +25 -2
  43. package/lib/init-update.mjs +246 -131
  44. package/lib/intake/classify.mjs +61 -183
  45. package/lib/intake/prepare.mjs +7 -0
  46. package/lib/intake/tables/creative.mjs +94 -0
  47. package/lib/intake/tables/operations.mjs +85 -0
  48. package/lib/intake/tables/research.mjs +85 -0
  49. package/lib/intake/tables/rnd.mjs +175 -0
  50. package/lib/intent-classifier.mjs +1 -0
  51. package/lib/knowledge/layout.mjs +10 -0
  52. package/lib/knowledge/research-store.mjs +109 -0
  53. package/lib/mcp/tools/telemetry.mjs +30 -78
  54. package/lib/model-router.mjs +61 -1
  55. package/lib/observation-store.mjs +19 -0
  56. package/lib/ollama-manager.mjs +1 -1
  57. package/lib/opencode-telemetry.mjs +4 -5
  58. package/lib/orchestration-policy.mjs +9 -0
  59. package/lib/outcomes/aggregate.mjs +104 -0
  60. package/lib/outcomes/record.mjs +115 -0
  61. package/lib/parity.mjs +121 -21
  62. package/lib/profiles/lifecycle.mjs +388 -0
  63. package/lib/profiles/loader.mjs +123 -0
  64. package/lib/profiles/validate-custom.mjs +114 -0
  65. package/lib/prompt-composer.js +106 -29
  66. package/lib/read-tracker-store.mjs +149 -0
  67. package/lib/reflect/extractor.mjs +193 -0
  68. package/lib/reflect.mjs +89 -2
  69. package/lib/sandbox.mjs +102 -0
  70. package/lib/server/index.mjs +76 -0
  71. package/lib/server/telemetry-login.mjs +17 -25
  72. package/lib/service-manager.mjs +30 -22
  73. package/lib/services/local-postgres.mjs +15 -0
  74. package/lib/services/telemetry-backend.mjs +1 -2
  75. package/lib/setup.mjs +8 -43
  76. package/lib/status.mjs +51 -5
  77. package/lib/storage/backend.mjs +12 -2
  78. package/lib/strategy-store.mjs +371 -0
  79. package/lib/telemetry/backends/local.mjs +6 -4
  80. package/lib/telemetry/client.mjs +185 -0
  81. package/lib/telemetry/ingest.mjs +13 -5
  82. package/lib/telemetry/team-rollup.mjs +9 -2
  83. package/lib/worker/trace.mjs +17 -27
  84. package/package.json +10 -2
  85. package/personas/construct.md +20 -20
  86. package/platforms/claude/settings.template.json +13 -0
  87. package/rules/common/research.md +44 -12
  88. package/scripts/sync-agents.mjs +11 -0
  89. package/skills/docs/backlog-proposal-workflow.md +2 -2
  90. package/skills/docs/customer-profile-workflow.md +1 -1
  91. package/skills/docs/evidence-ingest-workflow.md +5 -5
  92. package/skills/docs/prfaq-workflow.md +1 -1
  93. package/skills/docs/product-intelligence-review.md +1 -1
  94. package/skills/docs/product-intelligence-workflow.md +3 -3
  95. package/skills/docs/product-signal-workflow.md +48 -18
  96. package/skills/docs/research-workflow.md +26 -14
  97. package/skills/docs/strategy-workflow.md +36 -0
  98. package/skills/roles/architect.ai-systems.md +4 -2
  99. package/skills/roles/architect.data.md +4 -2
  100. package/skills/roles/architect.enterprise.md +4 -2
  101. package/skills/roles/architect.integration.md +4 -2
  102. package/skills/roles/architect.md +7 -5
  103. package/skills/roles/architect.platform.md +4 -2
  104. package/skills/roles/data-analyst.experiment.md +4 -2
  105. package/skills/roles/data-analyst.md +9 -7
  106. package/skills/roles/data-analyst.product-intelligence.md +5 -3
  107. package/skills/roles/data-analyst.product.md +4 -2
  108. package/skills/roles/data-analyst.telemetry.md +4 -2
  109. package/skills/roles/data-engineer.pipeline.md +4 -2
  110. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  111. package/skills/roles/data-engineer.warehouse.md +4 -2
  112. package/skills/roles/debugger.md +7 -5
  113. package/skills/roles/designer.accessibility.md +4 -2
  114. package/skills/roles/designer.md +10 -8
  115. package/skills/roles/engineer.ai.md +4 -2
  116. package/skills/roles/engineer.data.md +5 -3
  117. package/skills/roles/engineer.md +14 -12
  118. package/skills/roles/engineer.platform.md +5 -3
  119. package/skills/roles/operator.docs.md +6 -4
  120. package/skills/roles/operator.md +6 -4
  121. package/skills/roles/operator.release.md +4 -2
  122. package/skills/roles/operator.sre.md +5 -3
  123. package/skills/roles/orchestrator.md +5 -3
  124. package/skills/roles/product-manager.ai-product.md +4 -2
  125. package/skills/roles/product-manager.business-strategy.md +4 -2
  126. package/skills/roles/product-manager.enterprise.md +4 -2
  127. package/skills/roles/product-manager.growth.md +4 -2
  128. package/skills/roles/product-manager.md +6 -4
  129. package/skills/roles/product-manager.platform.md +4 -2
  130. package/skills/roles/product-manager.product.md +4 -2
  131. package/skills/roles/qa.ai-eval.md +4 -2
  132. package/skills/roles/qa.api-contract.md +4 -2
  133. package/skills/roles/qa.data-pipeline.md +4 -2
  134. package/skills/roles/qa.md +7 -5
  135. package/skills/roles/qa.test-automation.md +5 -3
  136. package/skills/roles/qa.web-ui.md +4 -2
  137. package/skills/roles/researcher.explorer.md +4 -2
  138. package/skills/roles/researcher.md +35 -20
  139. package/skills/roles/researcher.ux.md +4 -2
  140. package/skills/roles/reviewer.devil-advocate.md +4 -2
  141. package/skills/roles/reviewer.evaluator.md +4 -2
  142. package/skills/roles/reviewer.md +14 -12
  143. package/skills/roles/reviewer.trace.md +4 -2
  144. package/skills/roles/security.ai.md +4 -2
  145. package/skills/roles/security.appsec.md +4 -2
  146. package/skills/roles/security.cloud.md +4 -2
  147. package/skills/roles/security.legal-compliance.md +4 -2
  148. package/skills/roles/security.md +7 -5
  149. package/skills/roles/security.privacy.md +4 -2
  150. package/skills/roles/security.supply-chain.md +4 -2
  151. package/skills/routing.md +8 -1
  152. package/templates/docs/persona-artifact.md +36 -0
  153. package/templates/docs/research-brief.md +63 -9
  154. package/templates/docs/research-finding.md +26 -0
  155. package/templates/docs/skill-artifact.md +27 -0
  156. package/templates/docs/strategy.md +36 -0
  157. package/templates/homebrew/construct.rb +6 -6
@@ -324,6 +324,14 @@ export function classifyDataEngineerFlavor(request = '') {
324
324
  return null;
325
325
  }
326
326
 
327
+ export function classifyEngineerFlavor(request = '') {
328
+ const text = String(request).toLowerCase();
329
+ if (containsAny(text, ['llm', 'agent', 'prompt', 'eval', 'hallucination', 'rag', 'model behavior', 'model routing'])) return 'ai';
330
+ if (containsAny(text, ['vector', 'embedding', 'retrieval', 'warehouse', 'etl', 'elt', 'pipeline', 'streaming', 'data contract'])) return 'data';
331
+ if (containsAny(text, ['ci', 'build', 'deploy', 'docker', 'kubernetes', 'terraform', 'helm', 'release pipeline', 'platform tooling', 'developer experience'])) return 'platform';
332
+ return null;
333
+ }
334
+
327
335
  export function isDataAnalysisRequest(request = '') {
328
336
  return Boolean(classifyDataAnalystFlavor(request));
329
337
  }
@@ -406,6 +414,7 @@ export function isVisualDeliverableRequest(request = '') {
406
414
 
407
415
  export function classifyRoleFlavors(request = '') {
408
416
  return {
417
+ engineer: classifyEngineerFlavor(request),
409
418
  architect: classifyArchitectFlavor(request),
410
419
  productManager: isProductIntelligenceRequest(request) ? classifyProductManagerFlavor(request) : null,
411
420
  qa: classifyQaFlavor(request),
@@ -0,0 +1,104 @@
1
+ /**
2
+ * lib/outcomes/aggregate.mjs — Per-role rolling success-rate summary.
3
+ *
4
+ * Reads `.cx/outcomes/<role>.jsonl` files and produces `_summary.json`:
5
+ *
6
+ * { generatedAt, roles: { <role>: { count, success, successRate,
7
+ * p50DurationMs, last30: { count, successRate } } } }
8
+ *
9
+ * The classifier reads `successRate` to apply a tie-breaker soft re-rank
10
+ * (capped at ±0.05 in lib/intake/classify.mjs so it cannot invert the
11
+ * primary keyword signal).
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { listOutcomes } from './record.mjs';
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ const LAST_WINDOW_DAYS = 30;
19
+
20
+ function median(nums) {
21
+ if (nums.length === 0) return null;
22
+ const sorted = [...nums].sort((a, b) => a - b);
23
+ const mid = Math.floor(sorted.length / 2);
24
+ return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
25
+ }
26
+
27
+ function rollupRole(outcomes) {
28
+ const total = outcomes.length;
29
+ if (total === 0) return null;
30
+ const success = outcomes.filter((o) => o.success === true).length;
31
+ const durations = outcomes
32
+ .map((o) => o.durationMs)
33
+ .filter((d) => Number.isFinite(d) && d > 0);
34
+
35
+ const cutoff = Date.now() - LAST_WINDOW_DAYS * DAY_MS;
36
+ const recent = outcomes.filter((o) => Date.parse(o.ts) >= cutoff);
37
+ const recentSuccess = recent.filter((o) => o.success === true).length;
38
+
39
+ return {
40
+ count: total,
41
+ success,
42
+ successRate: total > 0 ? Number((success / total).toFixed(3)) : 0,
43
+ p50DurationMs: median(durations),
44
+ last30: {
45
+ count: recent.length,
46
+ successRate: recent.length > 0 ? Number((recentSuccess / recent.length).toFixed(3)) : 0,
47
+ },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * List every role with outcome data in this project. Returns role ids.
53
+ */
54
+ export function listRolesWithOutcomes(cwd) {
55
+ const dir = path.join(cwd, '.cx', 'outcomes');
56
+ if (!fs.existsSync(dir)) return [];
57
+ const seen = new Set();
58
+ for (const f of fs.readdirSync(dir)) {
59
+ const m = /^([a-z0-9-]+)(\.\d+)?\.jsonl$/.exec(f);
60
+ if (m) seen.add(m[1]);
61
+ }
62
+ return Array.from(seen).sort();
63
+ }
64
+
65
+ /**
66
+ * Rebuild the summary from all per-role JSONL files. Idempotent.
67
+ */
68
+ export function aggregateOutcomes(cwd) {
69
+ const roles = listRolesWithOutcomes(cwd);
70
+ const out = { generatedAt: new Date().toISOString(), roles: {} };
71
+ for (const role of roles) {
72
+ const rollup = rollupRole(listOutcomes(cwd, role));
73
+ if (rollup) out.roles[role] = rollup;
74
+ }
75
+ const dir = path.join(cwd, '.cx', 'outcomes');
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ fs.writeFileSync(path.join(dir, '_summary.json'), JSON.stringify(out, null, 2) + '\n');
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Read the cached summary without recomputing. Keeps the classifier
83
+ * tiebreaker on an O(1) hot path.
84
+ */
85
+ export function readSummary(cwd) {
86
+ const p = path.join(cwd, '.cx', 'outcomes', '_summary.json');
87
+ if (!fs.existsSync(p)) return null;
88
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
89
+ }
90
+
91
+ /**
92
+ * Tiebreaker score for the classifier. Returns a number in [-0.05, 0.05]
93
+ * derived from the role's recent success rate. Default behavior on missing
94
+ * data is 0 (no influence).
95
+ */
96
+ export function outcomeBoost(cwd, role, { cap = 0.05 } = {}) {
97
+ const summary = readSummary(cwd);
98
+ const stats = summary?.roles?.[role];
99
+ if (!stats || !stats.last30 || stats.last30.count < 3) return 0;
100
+ // Map [0.5, 1.0] success → [0, cap]; [0, 0.5] → [-cap, 0]. Linear, capped.
101
+ const rate = stats.last30.successRate;
102
+ const centered = (rate - 0.5) * 2; // [-1, 1]
103
+ return Math.max(-cap, Math.min(cap, centered * cap));
104
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * lib/outcomes/record.mjs — Specialist outcome recorder.
3
+ *
4
+ * Records the result of every dispatched specialist into
5
+ * `.cx/outcomes/<role>.jsonl`. The aggregator (lib/outcomes/aggregate.mjs)
6
+ * reads these files to produce per-role rolling success rates, which the
7
+ * classifier consumes as a soft tiebreaker.
8
+ *
9
+ * Schema (one JSONL line per outcome):
10
+ * { ts, role, intakeId, profile, success, escalated, durationMs, notes,
11
+ * source: 'agent-tracker' | 'feedback' | 'manual' }
12
+ *
13
+ * Files rotate at 10k lines per role to keep disk bounded. The rotated file
14
+ * is named `<role>.<yyyymm>.jsonl`. Current is always `<role>.jsonl`.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { withFileLockSync } from '../storage/file-lock.mjs';
19
+
20
+ const ROTATE_AT = 10_000;
21
+ const MAX_NOTES = 500;
22
+
23
+ function safeRole(role) {
24
+ return String(role || 'unknown').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 60) || 'unknown';
25
+ }
26
+
27
+ function outcomesDir(cwd) {
28
+ return path.join(cwd, '.cx', 'outcomes');
29
+ }
30
+
31
+ function activeFile(cwd, role) {
32
+ return path.join(outcomesDir(cwd), `${safeRole(role)}.jsonl`);
33
+ }
34
+
35
+ function rotatedFile(cwd, role) {
36
+ const stamp = new Date().toISOString().slice(0, 7).replace('-', ''); // yyyymm
37
+ return path.join(outcomesDir(cwd), `${safeRole(role)}.${stamp}.jsonl`);
38
+ }
39
+
40
+ function countLines(file) {
41
+ try {
42
+ const buf = fs.readFileSync(file);
43
+ let n = 0;
44
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) n++;
45
+ return n;
46
+ } catch { return 0; }
47
+ }
48
+
49
+ /**
50
+ * Record a specialist outcome. Best-effort: any IO failure is swallowed and
51
+ * the caller proceeds. Outcomes are telemetry, never on the critical path.
52
+ *
53
+ * @param {string} cwd - project root
54
+ * @param {object} payload
55
+ * @param {string} payload.role - specialist id (cx-engineer → 'engineer')
56
+ * @param {string} [payload.intakeId]
57
+ * @param {string} [payload.profile] - active profile id
58
+ * @param {boolean} payload.success
59
+ * @param {boolean} [payload.escalated]
60
+ * @param {number} [payload.durationMs]
61
+ * @param {string} [payload.notes]
62
+ * @param {string} [payload.source='manual']
63
+ * @returns {string|null} path to the file written, or null on failure
64
+ */
65
+ export function recordOutcome(cwd, payload) {
66
+ if (!cwd || !payload || typeof payload.success !== 'boolean') return null;
67
+ try {
68
+ fs.mkdirSync(outcomesDir(cwd), { recursive: true });
69
+ const file = activeFile(cwd, payload.role);
70
+
71
+ if (countLines(file) >= ROTATE_AT) {
72
+ try { fs.renameSync(file, rotatedFile(cwd, payload.role)); } catch { /* race */ }
73
+ }
74
+
75
+ const entry = {
76
+ ts: new Date().toISOString(),
77
+ role: safeRole(payload.role),
78
+ intakeId: payload.intakeId || null,
79
+ profile: payload.profile || null,
80
+ success: !!payload.success,
81
+ escalated: !!payload.escalated,
82
+ durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : null,
83
+ notes: payload.notes ? String(payload.notes).slice(0, MAX_NOTES) : null,
84
+ source: payload.source || 'manual',
85
+ };
86
+
87
+ withFileLockSync(file, () => {
88
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
89
+ });
90
+ return file;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read every outcome line for a role across active + rotated files.
98
+ * Returns oldest-first.
99
+ */
100
+ export function listOutcomes(cwd, role) {
101
+ const dir = outcomesDir(cwd);
102
+ if (!fs.existsSync(dir)) return [];
103
+ const safe = safeRole(role);
104
+ const files = fs.readdirSync(dir).filter((f) => f.startsWith(`${safe}.`) && f.endsWith('.jsonl'));
105
+ const out = [];
106
+ for (const f of files.sort()) {
107
+ let raw = '';
108
+ try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
109
+ for (const line of raw.split('\n')) {
110
+ if (!line.trim()) continue;
111
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
112
+ }
113
+ }
114
+ return out;
115
+ }
package/lib/parity.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * lib/parity.mjs — Cross-surface parity verifier.
3
3
  *
4
4
  * After `construct sync` writes adapters to multiple surfaces (Claude Code,
5
- * OpenCode, Codex), the module diffs each surface's actual state against the
5
+ * OpenCode, Codex, Copilot, VS Code, Cursor), the module diffs each surface's actual state against the
6
6
  * canonical `agents/registry.json`. Backs `construct doctor` for surfacing
7
7
  * silent divergence — for instance, an agent added to the registry that
8
8
  * never made it to OpenCode because of a sync regression.
@@ -14,9 +14,12 @@
14
14
  * the field are mirrored everywhere.
15
15
  *
16
16
  * Surfaces checked:
17
- * claude ~/.claude/agents/*.md
18
- * opencode ~/.config/opencode/opencode.json (agent table)
19
- * codex ~/.codex/agents/*.toml
17
+ * claude ~/.claude/agents/*.md
18
+ * opencode ~/.config/opencode/opencode.json (agent table)
19
+ * codex ~/.codex/agents/*.toml
20
+ * copilot ~/.github/prompts/*.prompt.md
21
+ * vscode VS Code user settings (github.copilot.mcpServers)
22
+ * cursor ~/.cursor/mcp.json
20
23
  */
21
24
 
22
25
  import fs from 'node:fs';
@@ -59,16 +62,15 @@ function diffSets(expected, actual) {
59
62
  return { missing, extra };
60
63
  }
61
64
 
62
- function checkClaude(registry, { homeDir = os.homedir() } = {}) {
63
- const dir = path.join(homeDir, '.claude', 'agents');
64
- if (!fs.existsSync(dir)) return { surface: 'claude', status: 'absent', dir };
65
- const expected = entriesForSurface(registry, 'claude');
65
+ function checkFileSurface({ surface, kind, dir, extension, expected }) {
66
+ if (!fs.existsSync(dir)) return { surface, kind, status: 'absent', dir };
66
67
  const actual = fs.readdirSync(dir)
67
- .filter((name) => name.endsWith('.md'))
68
- .map((name) => name.replace(/\.md$/, ''));
68
+ .filter((name) => name.endsWith(extension))
69
+ .map((name) => name.slice(0, -extension.length));
69
70
  const { missing, extra } = diffSets(expected, actual);
70
71
  return {
71
- surface: 'claude',
72
+ surface,
73
+ kind,
72
74
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
73
75
  dir,
74
76
  expectedCount: expected.length,
@@ -78,20 +80,56 @@ function checkClaude(registry, { homeDir = os.homedir() } = {}) {
78
80
  };
79
81
  }
80
82
 
83
+ function getVSCodeSettingsPaths(homeDir) {
84
+ const platform = os.platform();
85
+ if (platform === 'darwin') {
86
+ return [
87
+ path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'settings.json'),
88
+ path.join(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'settings.json'),
89
+ ];
90
+ }
91
+ if (platform === 'linux') {
92
+ return [
93
+ path.join(homeDir, '.config', 'Code', 'User', 'settings.json'),
94
+ path.join(homeDir, '.config', 'Code - Insiders', 'User', 'settings.json'),
95
+ ];
96
+ }
97
+ if (platform === 'win32') {
98
+ const appData = process.env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
99
+ return [
100
+ path.join(appData, 'Code', 'User', 'settings.json'),
101
+ path.join(appData, 'Code - Insiders', 'User', 'settings.json'),
102
+ ];
103
+ }
104
+ return [];
105
+ }
106
+
107
+ function checkClaude(registry, { homeDir = os.homedir() } = {}) {
108
+ const expected = entriesForSurface(registry, 'claude');
109
+ return checkFileSurface({
110
+ surface: 'claude',
111
+ kind: 'agents',
112
+ dir: path.join(homeDir, '.claude', 'agents'),
113
+ extension: '.md',
114
+ expected,
115
+ });
116
+ }
117
+
81
118
  function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
82
119
  const file = path.join(homeDir, '.config', 'opencode', 'opencode.json');
83
- if (!fs.existsSync(file)) return { surface: 'opencode', status: 'absent', file };
120
+ if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
84
121
  let config;
85
122
  try {
86
123
  config = JSON.parse(fs.readFileSync(file, 'utf8'));
87
124
  } catch (err) {
88
- return { surface: 'opencode', status: 'unreadable', file, error: err.message };
125
+ return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
89
126
  }
90
127
  const expected = entriesForSurface(registry, 'opencode');
91
128
  const actual = Object.keys(config.agent || config.agents || {});
92
129
  const { missing, extra } = diffSets(expected, actual);
93
130
  return {
94
131
  surface: 'opencode',
132
+ kind: 'agents',
95
133
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
96
134
  file,
97
135
  expectedCount: expected.length,
@@ -102,17 +140,76 @@ function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
102
140
  }
103
141
 
104
142
  function checkCodex(registry, { homeDir = os.homedir() } = {}) {
105
- const dir = path.join(homeDir, '.codex', 'agents');
106
- if (!fs.existsSync(dir)) return { surface: 'codex', status: 'absent', dir };
107
143
  const expected = entriesForSurface(registry, 'codex');
108
- const actual = fs.readdirSync(dir)
109
- .filter((name) => name.endsWith('.toml'))
110
- .map((name) => name.replace(/\.toml$/, ''));
144
+ return checkFileSurface({
145
+ surface: 'codex',
146
+ kind: 'agents',
147
+ dir: path.join(homeDir, '.codex', 'agents'),
148
+ extension: '.toml',
149
+ expected,
150
+ });
151
+ }
152
+
153
+ function checkCopilot(registry, { homeDir = os.homedir() } = {}) {
154
+ // Use the same helper as Claude/OpenCode/Codex so all surfaces apply the
155
+ // platform-filter rules identically. The previous implementation dropped
156
+ // entries with internal:true, which produced false drift because the sync
157
+ // script writes every specialist to ~/.github/prompts regardless of the
158
+ // internal flag.
159
+ const expected = entriesForSurface(registry, 'copilot');
160
+ return checkFileSurface({
161
+ surface: 'copilot',
162
+ kind: 'prompts',
163
+ dir: path.join(homeDir, '.github', 'prompts'),
164
+ extension: '.prompt.md',
165
+ expected,
166
+ });
167
+ }
168
+
169
+ function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
170
+ const paths = getVSCodeSettingsPaths(homeDir).filter((settingsPath) => fs.existsSync(settingsPath));
171
+ if (paths.length === 0) return { surface: 'vscode', kind: 'mcps', status: 'absent', paths };
172
+ const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
173
+ const actualSet = new Set();
174
+ for (const settingsPath of paths) {
175
+ try {
176
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
177
+ for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
178
+ } catch (err) {
179
+ return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
180
+ }
181
+ }
182
+ const actual = [...actualSet].sort();
111
183
  const { missing, extra } = diffSets(expected, actual);
112
184
  return {
113
- surface: 'codex',
185
+ surface: 'vscode',
186
+ kind: 'mcps',
114
187
  status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
115
- dir,
188
+ paths,
189
+ expectedCount: expected.length,
190
+ actualCount: actual.length,
191
+ missing,
192
+ extra,
193
+ };
194
+ }
195
+
196
+ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
197
+ const file = path.join(homeDir, '.cursor', 'mcp.json');
198
+ if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
199
+ let config;
200
+ try {
201
+ config = JSON.parse(fs.readFileSync(file, 'utf8'));
202
+ } catch (err) {
203
+ return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
204
+ }
205
+ const expected = Object.keys(registry.mcpServers ?? {}).filter((id) => id !== 'memory');
206
+ const actual = Object.keys(config.mcpServers ?? {});
207
+ const { missing, extra } = diffSets(expected, actual);
208
+ return {
209
+ surface: 'cursor',
210
+ kind: 'mcps',
211
+ status: missing.length === 0 && extra.length === 0 ? 'ok' : 'drift',
212
+ file,
116
213
  expectedCount: expected.length,
117
214
  actualCount: actual.length,
118
215
  missing,
@@ -130,13 +227,16 @@ export function checkParity({ rootDir = ROOT_DIR, homeDir = os.homedir() } = {})
130
227
  checkClaude(registry, { homeDir }),
131
228
  checkOpenCode(registry, { homeDir }),
132
229
  checkCodex(registry, { homeDir }),
230
+ checkCopilot(registry, { homeDir }),
231
+ checkVSCode(registry, { homeDir }),
232
+ checkCursor(registry, { homeDir }),
133
233
  ];
134
234
 
135
235
  const ok = surfaces.every((s) => s.status === 'ok' || s.status === 'absent');
136
236
  const summary = surfaces.map((s) => {
137
237
  if (s.status === 'absent') return `${s.surface}: not installed`;
138
238
  if (s.status === 'unreadable') return `${s.surface}: unreadable (${s.error})`;
139
- if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount})`;
239
+ if (s.status === 'ok') return `${s.surface}: ok (${s.actualCount}/${s.expectedCount} ${s.kind})`;
140
240
  const parts = [];
141
241
  if (s.missing.length) parts.push(`missing: ${s.missing.join(', ')}`);
142
242
  if (s.extra.length) parts.push(`extra: ${s.extra.join(', ')}`);