@geraldmaron/construct 1.0.11 → 1.0.14

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 (90) hide show
  1. package/README.md +55 -18
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +23 -22
  5. package/lib/cli-commands.mjs +2 -0
  6. package/lib/dashboard-static.mjs +7 -4
  7. package/lib/doc-stamp.mjs +16 -0
  8. package/lib/docs-verify.mjs +1 -8
  9. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  10. package/lib/embed/daemon.mjs +20 -0
  11. package/lib/embed/docs-lifecycle.mjs +19 -0
  12. package/lib/embed/inbox.mjs +85 -2
  13. package/lib/embed/recommendation-store.mjs +29 -0
  14. package/lib/gates-audit.mjs +18 -12
  15. package/lib/hooks/_lib/input.mjs +52 -0
  16. package/lib/hooks/adaptive-lint.mjs +4 -0
  17. package/lib/hooks/agent-tracker.mjs +59 -15
  18. package/lib/hooks/audit-reads.mjs +83 -42
  19. package/lib/hooks/audit-trail.mjs +28 -18
  20. package/lib/hooks/bash-output-logger.mjs +8 -2
  21. package/lib/hooks/block-no-verify.mjs +4 -0
  22. package/lib/hooks/ci-status-check.mjs +4 -0
  23. package/lib/hooks/comment-lint.mjs +6 -4
  24. package/lib/hooks/config-protection.mjs +4 -0
  25. package/lib/hooks/context-watch.mjs +4 -0
  26. package/lib/hooks/context-window-recovery.mjs +4 -0
  27. package/lib/hooks/dep-audit.mjs +12 -5
  28. package/lib/hooks/doc-coupling-check.mjs +5 -1
  29. package/lib/hooks/edit-accumulator.mjs +25 -10
  30. package/lib/hooks/edit-error-recovery.mjs +4 -0
  31. package/lib/hooks/edit-guard.mjs +4 -0
  32. package/lib/hooks/guard-bash.mjs +4 -0
  33. package/lib/hooks/mcp-audit.mjs +4 -0
  34. package/lib/hooks/mcp-health-check.mjs +4 -0
  35. package/lib/hooks/model-fallback.mjs +7 -11
  36. package/lib/hooks/policy-engine.mjs +4 -0
  37. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  38. package/lib/hooks/post-merge-tracking.mjs +82 -0
  39. package/lib/hooks/pre-compact.mjs +4 -0
  40. package/lib/hooks/pre-push-gate.mjs +84 -231
  41. package/lib/hooks/proactive-activation.mjs +5 -2
  42. package/lib/hooks/readme-age-check.mjs +4 -0
  43. package/lib/hooks/registry-sync.mjs +35 -20
  44. package/lib/hooks/rule-verifier.mjs +3 -0
  45. package/lib/hooks/scan-secrets.mjs +4 -0
  46. package/lib/hooks/session-optimize.mjs +4 -0
  47. package/lib/hooks/session-reflect.mjs +4 -0
  48. package/lib/hooks/session-start.mjs +48 -1
  49. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  50. package/lib/hooks/stop-notify.mjs +13 -2
  51. package/lib/hooks/stop-typecheck.mjs +4 -0
  52. package/lib/hooks/test-watch.mjs +8 -4
  53. package/lib/init-unified.mjs +90 -23
  54. package/lib/intake/attribution.mjs +77 -0
  55. package/lib/intake/intake-config.mjs +3 -0
  56. package/lib/intake/manifest.mjs +107 -0
  57. package/lib/intake/poll-lock.mjs +136 -0
  58. package/lib/intake/prepare.mjs +42 -4
  59. package/lib/logging/rotate.mjs +394 -0
  60. package/lib/mcp/tools/project.mjs +2 -2
  61. package/lib/mcp/tools/telemetry.mjs +1 -1
  62. package/lib/opencode-config.mjs +10 -3
  63. package/lib/orchestration/routing-tables.mjs +176 -0
  64. package/lib/orchestration-policy.mjs +18 -106
  65. package/lib/parity.mjs +48 -7
  66. package/lib/profiles/lifecycle.mjs +19 -1
  67. package/lib/project-init-shared.mjs +11 -5
  68. package/lib/project-root.mjs +104 -0
  69. package/lib/roles/catalog.mjs +1 -1
  70. package/lib/roles/event-bus.mjs +29 -9
  71. package/lib/roles/router.mjs +8 -7
  72. package/lib/server/index.mjs +31 -9
  73. package/lib/server/static/index.html +1 -15
  74. package/lib/specialist-contracts-enforce.mjs +24 -10
  75. package/lib/status.mjs +1 -1
  76. package/lib/storage/backup.mjs +2 -2
  77. package/lib/sync/skill-frontmatter.mjs +71 -0
  78. package/lib/telemetry/intent-verifications.mjs +16 -3
  79. package/lib/telemetry/skill-calls.mjs +12 -3
  80. package/lib/tracking-surfaces.mjs +375 -0
  81. package/lib/worker/trace.mjs +19 -2
  82. package/package.json +6 -2
  83. package/platforms/claude/settings.template.json +28 -27
  84. package/scripts/sync-specialists.mjs +296 -94
  85. package/specialists/registry.json +158 -13
  86. package/lib/hooks/env-check.mjs +0 -83
  87. package/lib/hooks/read-tracker.mjs +0 -61
  88. package/lib/policy/unified-gates.mjs +0 -96
  89. package/lib/server/static/assets/index-ab25c707.js +0 -70
  90. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -2,13 +2,27 @@
2
2
  /**
3
3
  * sync-specialists.mjs — regenerate agent adapters for all platforms from specialists/registry.json.
4
4
  *
5
- * Reads registry.json, resolves env vars and model tiers, then writes Claude Code,
6
- * OpenCode, Codex, Copilot, VS Code, and Cursor adapters. Called by 'construct sync'.
5
+ * Two scopes, modelled on every host's own convention (global = personal default,
6
+ * project = team-shared):
7
+ *
8
+ * Global / user scope (`~/.claude/`, `~/.codex/`, `~/.github/`, …)
9
+ * - Only the `construct` front-door agent (the registry's top-level
10
+ * `orchestrator` entry). Specialists, slash commands, and skills do NOT
11
+ * land at global scope — they are project content.
12
+ * - Plus the hook installer in `~/.claude/settings.json`, which has to be
13
+ * global so hooks fire in every Claude Code session.
14
+ *
15
+ * Project scope (`<project>/.claude/`, `<project>/.codex/`, `<project>/.github/`, …)
16
+ * - `construct` + all 28 `cx-*` specialists, slash commands, skills, MCP
17
+ * wiring. Files version with the repo and travel with teammates via git.
7
18
  *
8
19
  * Flags:
9
20
  * --dry-run Print a diff of what would change without writing anything.
10
21
  * --force Bypass prompt word-cap hard stop (still warns).
11
- * --project Write to the current project's .claude/ directory only.
22
+ * --project Write only the project tier (cwd's `.claude/`, `.codex/`, etc.).
23
+ * --global Write only the global tier (orchestrator + hooks at `~/`).
24
+ * (no scope flag) Write the global tier. If cwd is inside a Construct
25
+ * project, also write the project tier.
12
26
  * --compress-personas Run the engine's Compressor on every persona prompt
13
27
  * before writing platform adapters. The source persona
14
28
  * file is unchanged; only the runtime adapter is shorter.
@@ -41,6 +55,7 @@ import { inlineRoleAntiPatterns, PROMPT_WORD_CAP } from "../lib/role-preload.mjs
41
55
  import { resolveActiveProfile } from "../lib/profiles/loader.mjs";
42
56
  import { resolveTiersForPrimary } from "../lib/model-router.mjs";
43
57
  import { stampFrontmatter } from "../lib/doc-stamp.mjs";
58
+ import { buildSkillFrontmatter, stripLeadingFrontmatter } from "../lib/sync/skill-frontmatter.mjs";
44
59
 
45
60
  const home = os.homedir();
46
61
  const root = path.resolve(import.meta.dirname, "..");
@@ -138,10 +153,36 @@ if (validationErrors.length > 0) {
138
153
 
139
154
  const DRY_RUN = process.argv.includes("--dry-run");
140
155
  const COMPRESS_PERSONAS = process.argv.includes("--compress-personas");
141
- const projectDir = process.argv.includes("--project") ? process.cwd() : null;
156
+ const PROJECT_FLAG = process.argv.includes("--project");
157
+ const GLOBAL_FLAG = process.argv.includes("--global");
158
+
159
+ /**
160
+ * A Construct project carries `.construct/` (the launcher staged by
161
+ * `stage-project.mjs`) or `.cx/` (state). When `construct sync` runs inside
162
+ * one without an explicit scope flag, default to project mode so specialists
163
+ * land with the repo rather than leaking into the user's home directory.
164
+ * `--global` overrides this for the front-door refresh path.
165
+ */
166
+ function detectConstructProject(cwd) {
167
+ if (fs.existsSync(path.join(cwd, ".construct")) || fs.existsSync(path.join(cwd, ".cx"))) {
168
+ return cwd;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ const detectedProject = (!PROJECT_FLAG && !GLOBAL_FLAG) ? detectConstructProject(process.cwd()) : null;
174
+ const projectDir = PROJECT_FLAG ? process.cwd() : detectedProject;
142
175
  const lockPath = path.join(projectDir || root, ".cx", "sync.lock");
143
176
  const stagingDir = path.join(projectDir || root, ".cx", "sync-staging");
144
177
 
178
+ // Project-tier writes carry every registry entry. Global-tier writes carry only
179
+ // the `construct` front-door agent — specialists live with the project, not the
180
+ // user's home directory, per each host's documented best-practice scope.
181
+
182
+ function globalEntries(allEntries) {
183
+ return allEntries.filter((e) => e.isOrchestrator);
184
+ }
185
+
145
186
  /** Acquire an exclusive lockfile. Aborts if already held by a live process. */
146
187
  function acquireLock() {
147
188
  if (DRY_RUN) return;
@@ -180,9 +221,18 @@ function releaseLock() {
180
221
  */
181
222
  const _stagedPairs = []; // [{ staging, real, content }]
182
223
 
183
- function writeFile(file, content) {
224
+ function writeFile(file, content, { stamp = true } = {}) {
184
225
  mkdirp(path.dirname(file));
185
- const stamped = file.endsWith('.md') ? stampFrontmatter(content, { generator: 'construct/sync-specialists' }) : content;
226
+ // Doc-stamp wraps content with cx_doc_id + body_hash YAML frontmatter for
227
+ // tamper detection. That's right for content artifacts (research findings,
228
+ // knowledge files), wrong for host-platform adapter files that have their
229
+ // own frontmatter contract (Claude Code agents, Copilot prompts, Anthropic
230
+ // Agent Skills) or are user-managed (CLAUDE.md, copilot-instructions.md).
231
+ // Stamping those produces double-frontmatter that breaks the host loader.
232
+ // Callers writing those files pass { stamp: false }.
233
+
234
+ const shouldStamp = stamp && file.endsWith('.md');
235
+ const stamped = shouldStamp ? stampFrontmatter(content, { generator: 'construct/sync-specialists' }) : content;
186
236
 
187
237
  if (DRY_RUN) {
188
238
  // Stage in memory only — compare against current on-disk content.
@@ -488,6 +538,7 @@ function removeStaleAdapters(dir, ext, entries) {
488
538
  }
489
539
 
490
540
  // Stale manifest entries — delete files not in the current expected set.
541
+
491
542
  const previouslyWritten = readManifest(dir);
492
543
  for (const file of previouslyWritten) {
493
544
  if (!expected.has(file) && fs.existsSync(path.join(dir, file))) {
@@ -498,6 +549,28 @@ function removeStaleAdapters(dir, ext, entries) {
498
549
  writeManifest(dir, expected);
499
550
  }
500
551
 
552
+ /**
553
+ * Sweep registry-managed `${prefix}-<specialist>${ext}` files in a global-scope
554
+ * adapter directory. Global scope only ever emits the `construct` front-door
555
+ * agent, so any file whose name matches a registered specialist is out of
556
+ * contract for global scope and gets removed. User-authored files that happen
557
+ * to share the `${prefix}-` prefix but a name outside the registry (e.g. a
558
+ * personal `cx-mytool.md`) are preserved. Idempotent: a second call finds
559
+ * nothing to delete. Anything in `expectedNames` is preserved so an in-mode
560
+ * write isn't undone.
561
+ */
562
+ function sweepLegacyPrefixedFiles(dir, ext, expectedNames) {
563
+ if (!fs.existsSync(dir)) return;
564
+ const keep = new Set(expectedNames);
565
+ const managed = new Set((registry.specialists ?? []).map((s) => `${agentPrefix}${s.name}${ext}`));
566
+ for (const file of fs.readdirSync(dir)) {
567
+ if (!file.endsWith(ext)) continue;
568
+ if (!managed.has(file)) continue;
569
+ if (keep.has(file)) continue;
570
+ try { fs.unlinkSync(path.join(dir, file)); } catch { /* already gone */ }
571
+ }
572
+ }
573
+
501
574
  // --- Unified entry list: personas + agents ---
502
575
 
503
576
  function buildEntries() {
@@ -638,12 +711,21 @@ function syncClaude(entries, targetDir = null) {
638
711
  : path.join(home, ".claude", "agents");
639
712
  if (!DRY_RUN) mkdirp(claudeAgentsDir);
640
713
 
641
- for (const entry of entries) {
714
+ // Global scope ships only the `construct` front-door agent; specialists are
715
+ // project content. removeStaleAdapters will sweep any cx-* files left over
716
+ // from previous syncs because they fall out of the manifest set.
717
+
718
+ const writeEntries = targetDir ? entries : globalEntries(entries);
719
+
720
+ for (const entry of writeEntries) {
642
721
  const name = adapterName(entry);
643
722
  const md = claudeAgentMarkdown(entry, entries);
644
- writeFile(path.join(claudeAgentsDir, `${name}.md`), md);
723
+ writeFile(path.join(claudeAgentsDir, `${name}.md`), md, { stamp: false });
724
+ }
725
+ removeStaleAdapters(claudeAgentsDir, ".md", writeEntries);
726
+ if (!targetDir) {
727
+ sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", writeEntries.map((e) => `${adapterName(e)}.md`));
645
728
  }
646
- removeStaleAdapters(claudeAgentsDir, ".md", entries);
647
729
 
648
730
  if (targetDir) {
649
731
  writeProjectClaudeSettings(targetDir);
@@ -654,16 +736,15 @@ function syncClaude(entries, targetDir = null) {
654
736
  const claudeMdPath = path.join(home, ".claude", "CLAUDE.md");
655
737
  const existing = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, "utf8") : "# Claude Global Instructions\n";
656
738
  const personaList = entries.filter((e) => e.isOrchestrator).map((e) => `- \`${adapterName(e)}\`: ${e.role} — ${e.description}`).join("\n");
657
- // Only surface non-internal specialists in CLAUDE.md — internal specialists are dispatched by Construct, not invoked by users
658
- const specialistList = entries.filter((e) => !e.isOrchestrator && !e.internal).map((e) => `- \`${adapterName(e)}\`: ${e.description}`).join("\n");
659
739
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Personas
660
740
 
661
741
  ${personaList}
662
742
 
663
743
  ## Internal Specialists
664
744
 
665
- ${specialistList || "(all specialists are internal — routed through Construct)"}`;
666
- writeFile(claudeMdPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd));
745
+ (all specialists are internal — routed through Construct, available inside a Construct-initialized project)`;
746
+ // User-managed file with our managed-block carved out — never doc-stamp.
747
+ writeFile(claudeMdPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
667
748
 
668
749
  // Sync MCP servers into ~/.claude/settings.json if it exists
669
750
  const claudeSettingsPath = path.join(home, ".claude", "settings.json");
@@ -734,19 +815,28 @@ function isCodexMcpSupported() {
734
815
  return true;
735
816
  }
736
817
 
737
- function syncCodex(entries) {
738
- const codexDir = path.join(home, ".codex");
818
+ function syncCodex(entries, targetDir = null) {
819
+ const codexDir = targetDir
820
+ ? path.join(targetDir, ".codex")
821
+ : path.join(home, ".codex");
739
822
  const codexAgentsDir = path.join(codexDir, "agents");
740
823
  if (!DRY_RUN) mkdirp(codexAgentsDir);
741
824
 
742
- for (const entry of entries) {
825
+ const writeEntries = targetDir ? entries : globalEntries(entries);
826
+
827
+ for (const entry of writeEntries) {
743
828
  writeFile(path.join(codexAgentsDir, `${adapterName(entry)}.toml`), codexAgentToml(entry, entries));
744
829
  }
745
- removeStaleAdapters(codexAgentsDir, ".toml", entries);
830
+ removeStaleAdapters(codexAgentsDir, ".toml", writeEntries);
831
+ if (!targetDir) {
832
+ sweepLegacyPrefixedFiles(codexAgentsDir, ".toml", writeEntries.map((e) => `${adapterName(e)}.toml`));
833
+ }
746
834
 
747
- const configPath = getCodexConfigPath(home);
835
+ const configPath = targetDir
836
+ ? path.join(codexDir, "config.toml")
837
+ : getCodexConfigPath(home);
748
838
  const existing = removeDanglingConstructMcpMarkers(removeDanglingConstructMcpTimeouts(readCodexConfig(configPath)));
749
- const entryNames = entries.map(adapterName);
839
+ const entryNames = writeEntries.map(adapterName);
750
840
  const registryMcp = registry.mcpServers ?? {};
751
841
  const existingMcpIds = Object.keys(registryMcp).filter((id) => hasCodexMcpTable(existing, id));
752
842
  const mcpIds = existingMcpIds.filter(isCodexMcpSupported);
@@ -756,8 +846,13 @@ function syncCodex(entries) {
756
846
  ));
757
847
  const hasAgentsRoot = /^\[agents\]\s*$/m.test(withoutManagedTables);
758
848
  const rootBlock = hasAgentsRoot ? "" : "[agents]\nmax_threads = 6\nmax_depth = 1\n\n";
759
- // Only expose non-internal agents in Codex config; internal agents are dispatched by Construct
760
- const blocks = entries.filter((e) => !e.internal).map((e) => `[agents.${adapterName(e)}]
849
+
850
+ // In project scope every entry is reachable from the user (Construct dispatches
851
+ // internal specialists itself, but project teammates may want to address them).
852
+ // In global scope only the `construct` front-door agent is registered.
853
+
854
+ const exposed = targetDir ? writeEntries : writeEntries.filter((e) => !e.internal);
855
+ const blocks = exposed.map((e) => `[agents.${adapterName(e)}]
761
856
  description = ${tomlString(e.description)}
762
857
  config_file = ${tomlString(`agents/${adapterName(e)}.toml`)}
763
858
  `).join("\n");
@@ -793,25 +888,44 @@ When using this prompt, stay within the role above and adapt to the current repo
793
888
  `;
794
889
  }
795
890
 
796
- function syncCopilot(entries) {
797
- const promptsDir = path.join(home, ".github", "prompts");
891
+ function syncCopilot(entries, targetDir = null) {
892
+ const promptsDir = targetDir
893
+ ? path.join(targetDir, ".github", "prompts")
894
+ : path.join(home, ".github", "prompts");
798
895
  if (!DRY_RUN) mkdirp(promptsDir);
799
- for (const entry of entries) {
800
- writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries));
896
+
897
+ const writeEntries = targetDir ? entries : globalEntries(entries);
898
+
899
+ for (const entry of writeEntries) {
900
+ writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
901
+ }
902
+ removeStaleAdapters(promptsDir, ".prompt.md", writeEntries);
903
+ if (!targetDir) {
904
+ sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
801
905
  }
802
- removeStaleAdapters(promptsDir, ".prompt.md", entries);
803
906
 
804
- const instructionsPath = path.join(home, ".github", "copilot-instructions.md");
907
+ const instructionsPath = targetDir
908
+ ? path.join(targetDir, ".github", "copilot-instructions.md")
909
+ : path.join(home, ".github", "copilot-instructions.md");
805
910
  const existing = fs.existsSync(instructionsPath)
806
911
  ? fs.readFileSync(instructionsPath, "utf8")
807
912
  : "# GitHub Copilot Instructions\n";
808
- const list = entries.filter((e) => !e.internal).map((e) => `- \`${adapterName(e)}\`: use \`~/.github/prompts/${adapterName(e)}.prompt.md\`.`).join("\n");
913
+
914
+ // Project-scope instructions list every entry; global instructions list only
915
+ // `construct` so user-scope Copilot exposes a single front door.
916
+
917
+ const listEntries = targetDir ? entries.filter((e) => !e.internal) : writeEntries;
918
+ const promptPathPrefix = targetDir ? ".github/prompts" : "~/.github/prompts";
919
+ const list = listEntries.map((e) => `- \`${adapterName(e)}\`: use \`${promptPathPrefix}/${adapterName(e)}.prompt.md\`.`).join("\n");
809
920
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
810
921
 
811
922
  Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
812
923
 
813
- ${list || "(all specialists are internal — use construct for all tasks)"}`;
814
- writeFile(instructionsPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd));
924
+ ${list || "(no front-door prompts to surface)"}`;
925
+
926
+ // User-managed file with the managed block carved out — never doc-stamp.
927
+
928
+ writeFile(instructionsPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
815
929
  }
816
930
 
817
931
  // --- VS Code adapter ---
@@ -839,13 +953,41 @@ function getVSCodeSettingsPaths() {
839
953
  return candidates.filter(fs.existsSync);
840
954
  }
841
955
 
842
- function syncVSCode() {
843
- const settingsPaths = getVSCodeSettingsPaths();
844
- if (settingsPaths.length === 0) return false;
845
-
956
+ function syncVSCode(targetDir = null) {
846
957
  const registryMcp = registry.mcpServers ?? {};
847
958
  if (Object.keys(registryMcp).length === 0) return false;
848
959
 
960
+ // Project scope writes a dedicated `.vscode/mcp.json` (VS Code's documented
961
+ // workspace MCP config). Global scope mutates the user's `settings.json`
962
+ // under `github.copilot.mcpServers`, preserving any non-Construct entries.
963
+
964
+ if (targetDir) {
965
+ const mcpPath = path.join(targetDir, ".vscode", "mcp.json");
966
+ let config = { servers: {} };
967
+ if (fs.existsSync(mcpPath)) {
968
+ try { config = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || { servers: {} }; }
969
+ catch { config = { servers: {} }; }
970
+ }
971
+ if (!config.servers) config.servers = {};
972
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
973
+ const existingEntry = config.servers[id];
974
+ const existing = JSON.stringify(existingEntry ?? "");
975
+ const hasPlaceholder = existing.includes("__");
976
+ const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
977
+ const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
978
+ const transportMismatch = registryWantsCommand && existingIsRemote;
979
+ if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
980
+ config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
981
+ }
982
+ if (!DRY_RUN) {
983
+ mkdirp(path.dirname(mcpPath));
984
+ fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
985
+ }
986
+ return true;
987
+ }
988
+
989
+ const settingsPaths = getVSCodeSettingsPaths();
990
+ if (settingsPaths.length === 0) return false;
849
991
  let synced = false;
850
992
  for (const settingsPath of settingsPaths) {
851
993
  try {
@@ -865,40 +1007,64 @@ function syncVSCode() {
865
1007
  settings["github.copilot.mcpServers"] = mcpServers;
866
1008
  if (!DRY_RUN) fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
867
1009
  synced = true;
868
- } catch {
869
- // Skip unreadable settings files
870
- }
1010
+ } catch { /* unreadable settings file */ }
871
1011
  }
872
1012
  return synced;
873
1013
  }
874
1014
 
875
1015
  // --- Cursor adapter ---
876
1016
 
877
- function syncCursor() {
878
- const cursorMcpPath = path.join(home, ".cursor", "mcp.json");
879
- if (!fs.existsSync(cursorMcpPath)) return false;
880
-
1017
+ function syncCursor(targetDir = null) {
881
1018
  const registryMcp = registry.mcpServers ?? {};
882
1019
  if (Object.keys(registryMcp).length === 0) return false;
883
1020
 
884
- try {
885
- const config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8"));
886
- if (!config.mcpServers) config.mcpServers = {};
887
- for (const [id, mcpDef] of Object.entries(registryMcp)) {
888
- const existingEntry = config.mcpServers[id];
889
- const existing = JSON.stringify(existingEntry ?? "");
890
- const hasPlaceholder = existing.includes("__");
891
- const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
892
- const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
893
- const transportMismatch = registryWantsCommand && existingIsRemote;
894
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
895
- config.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1021
+ const cursorMcpPath = targetDir
1022
+ ? path.join(targetDir, ".cursor", "mcp.json")
1023
+ : path.join(home, ".cursor", "mcp.json");
1024
+
1025
+ // Global scope only updates Cursor's MCP config when the user has already
1026
+ // initialized `~/.cursor/mcp.json`; we don't conjure user-scope config out
1027
+ // of thin air. Project scope always writes — `.cursor/mcp.json` is the
1028
+ // documented per-project mechanism and travels with the repo.
1029
+
1030
+ if (!targetDir && !fs.existsSync(cursorMcpPath)) return false;
1031
+
1032
+ let config = { mcpServers: {} };
1033
+ if (fs.existsSync(cursorMcpPath)) {
1034
+ try { config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8")) || { mcpServers: {} }; }
1035
+ catch { return false; }
1036
+ }
1037
+ if (!config.mcpServers) config.mcpServers = {};
1038
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
1039
+ const existingEntry = config.mcpServers[id];
1040
+ const existing = JSON.stringify(existingEntry ?? "");
1041
+ const hasPlaceholder = existing.includes("__");
1042
+ const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1043
+ const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1044
+ const transportMismatch = registryWantsCommand && existingIsRemote;
1045
+ if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1046
+ config.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1047
+ }
1048
+ if (!DRY_RUN) {
1049
+ mkdirp(path.dirname(cursorMcpPath));
1050
+ fs.writeFileSync(cursorMcpPath, JSON.stringify(config, null, 2) + "\n");
1051
+ }
1052
+
1053
+ // Project scope also emits a minimal `.cursor/rules/construct.mdc` so Cursor
1054
+ // surfaces a rules entry describing Construct without polluting global rules
1055
+ // (Cursor rules are always per-project by design).
1056
+
1057
+ if (targetDir) {
1058
+ const rulesPath = path.join(targetDir, ".cursor", "rules", "construct.mdc");
1059
+ if (!fs.existsSync(rulesPath)) {
1060
+ const body = `---\ndescription: Construct front-door — invoke \`construct\` for orchestration\nalwaysApply: false\n---\n\nThis project uses Construct (\`@geraldmaron/construct\`) as the single agent\nentry point. Route work through the \`construct\` persona; specialists are\ninternal and dispatched by Construct itself.\n\nSee \`.claude/agents/\` for the registered agents in this project.\n`;
1061
+ if (!DRY_RUN) {
1062
+ mkdirp(path.dirname(rulesPath));
1063
+ fs.writeFileSync(rulesPath, body);
1064
+ }
896
1065
  }
897
- if (!DRY_RUN) fs.writeFileSync(cursorMcpPath, JSON.stringify(config, null, 2) + "\n");
898
- return true;
899
- } catch {
900
- return false;
901
1066
  }
1067
+ return true;
902
1068
  }
903
1069
 
904
1070
  // --- OpenCode adapter ---
@@ -919,14 +1085,28 @@ function opencodeTaskPermissions(entry) {
919
1085
  };
920
1086
  }
921
1087
 
922
- function syncOpencode(entries) {
923
- const configPath = findOpenCodeConfigPath();
924
- if (!fs.existsSync(configPath)) return false;
925
- const pluginsDir = path.join(home, ".config", "opencode", "plugins");
1088
+ function syncOpencode(entries, targetDir = null) {
1089
+ // Project scope writes `.opencode/config.json` (OpenCode's documented
1090
+ // per-project config layer, which shadows `~/.config/opencode/`). Global
1091
+ // scope writes the user-level config and only when it already exists.
1092
+
1093
+ const configPath = targetDir
1094
+ ? path.join(targetDir, ".opencode", "config.json")
1095
+ : findOpenCodeConfigPath();
1096
+ if (!targetDir && !fs.existsSync(configPath)) return false;
1097
+ if (targetDir) mkdirp(path.dirname(configPath));
1098
+
1099
+ const pluginsDir = targetDir
1100
+ ? path.join(targetDir, ".opencode", "plugins")
1101
+ : path.join(home, ".config", "opencode", "plugins");
926
1102
  const managedPluginPath = path.join(pluginsDir, "construct-fallback.js");
927
1103
  const toolkitPluginPath = path.join(root, "platforms", "opencode", "plugins", "construct-fallback.js");
928
1104
 
929
- const { config } = readOpenCodeConfig();
1105
+ const writeEntries = targetDir ? entries : globalEntries(entries);
1106
+
1107
+ const { config } = targetDir
1108
+ ? { config: fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {} }
1109
+ : readOpenCodeConfig();
930
1110
  if (!config.agent) config.agent = {};
931
1111
  if (!Array.isArray(config.plugin)) config.plugin = [];
932
1112
  config.plugin = config.plugin.filter((entry) => {
@@ -1010,18 +1190,21 @@ function syncOpencode(entries) {
1010
1190
  }
1011
1191
  }
1012
1192
 
1013
- // Remove stale agents that Construct manages.
1193
+ // Sweep cx-* / orchestrator agents that fall outside the current write set.
1194
+ // In global scope this also removes any `cx-*` left over from prior syncs.
1195
+
1014
1196
  const prefixes = [agentPrefix];
1015
1197
  for (const key of Object.keys(config.agent)) {
1016
1198
  const isManaged = prefixes.some((p) => key.startsWith(p));
1017
1199
  const isOrchestrator = registry.orchestrator?.name === key;
1018
- if ((isManaged || isOrchestrator) && !entries.find((e) => adapterName(e) === key)) {
1200
+ if ((isManaged || isOrchestrator) && !writeEntries.find((e) => adapterName(e) === key)) {
1019
1201
  delete config.agent[key];
1020
1202
  }
1021
1203
  }
1022
1204
 
1023
- // Write agents — no model/modelFallback set; agents inherit the global model
1024
- for (const entry of entries) {
1205
+ // Write agents — no model/modelFallback set; agents inherit the global model.
1206
+
1207
+ for (const entry of writeEntries) {
1025
1208
  const name = adapterName(entry);
1026
1209
  const perms = opencodePermissions(entry);
1027
1210
  config.agent[name] = {
@@ -1060,12 +1243,16 @@ function syncOpencode(entries) {
1060
1243
  // --- Slash commands adapter ---
1061
1244
 
1062
1245
  function syncCommands(targetDir = null) {
1246
+ // Slash commands describe project-shaped workflows (release, init, sync …)
1247
+ // and belong with the repo so teammates pick them up via git. Project scope
1248
+ // only — global scope is a no-op.
1249
+
1250
+ if (!targetDir) return 0;
1251
+
1063
1252
  const sourceCommandsDir = path.join(root, "commands");
1064
1253
  if (!fs.existsSync(sourceCommandsDir)) return 0;
1065
1254
 
1066
- const claudeCommandsDir = targetDir
1067
- ? path.join(targetDir, ".claude", "commands")
1068
- : path.join(home, ".claude", "commands");
1255
+ const claudeCommandsDir = path.join(targetDir, ".claude", "commands");
1069
1256
 
1070
1257
  let count = 0;
1071
1258
  for (const domain of fs.readdirSync(sourceCommandsDir, { withFileTypes: true })) {
@@ -1143,32 +1330,35 @@ function collectSkills() {
1143
1330
  return results;
1144
1331
  }
1145
1332
 
1146
- /**
1147
- * Prefix prepended to every generated SKILL.md. The comment must survive
1148
- * round-trips — it is the canonical signal that a file was produced by sync
1149
- * and must not be hand-edited.
1150
- */
1151
- const SKILL_GENERATED_PREFIX = "# Generated by construct sync\n\n";
1152
-
1153
1333
  /**
1154
1334
  * Write collected skills to both .claude/skills/ and .agents/skills/ in
1155
- * SKILL.md directory format.
1156
- *
1157
- * Idempotency: content written is deterministic; writing the same content
1158
- * twice is a no-op because writeFile skips unchanged files in dry-run mode
1159
- * and two-phase staging in normal mode produces identical results.
1335
+ * SKILL.md directory format. Each file gets Anthropic Agent Skills frontmatter
1336
+ * (name + description) so the loader can index it. Doc-stamping is opted out
1337
+ * a doc-stamp YAML block before the real frontmatter produces double-
1338
+ * frontmatter the loader can't parse.
1160
1339
  */
1161
- function syncSkills() {
1340
+ function syncSkills(targetDir = null) {
1341
+ // Skills are project content — they describe domain knowledge a team shares,
1342
+ // not a user's personal default. Global scope writes nothing; project scope
1343
+ // writes to `<project>/.claude/skills/` (the documented Anthropic Agent
1344
+ // Skills path).
1345
+
1346
+ if (!targetDir) return 0;
1347
+
1162
1348
  const skills = collectSkills();
1163
1349
  if (skills.length === 0) return 0;
1164
1350
 
1165
- const claudeSkillsDir = path.join(home, ".claude", "skills");
1166
- const agentsSkillsDir = path.join(home, ".agents", "skills");
1351
+ const claudeSkillsDir = path.join(targetDir, ".claude", "skills");
1167
1352
 
1168
1353
  for (const { name, content } of skills) {
1169
- const generated = `${SKILL_GENERATED_PREFIX}${content}`;
1170
- writeFile(path.join(claudeSkillsDir, name, "SKILL.md"), generated);
1171
- writeFile(path.join(agentsSkillsDir, name, "SKILL.md"), generated);
1354
+ const frontmatter = buildSkillFrontmatter(name, content);
1355
+
1356
+ // Strip any existing frontmatter from the source body so we don't emit two
1357
+ // blocks if a hand-authored skill already carries one.
1358
+
1359
+ const body = stripLeadingFrontmatter(content);
1360
+ const generated = `${frontmatter}\n${body}`;
1361
+ writeFile(path.join(claudeSkillsDir, name, "SKILL.md"), generated, { stamp: false });
1172
1362
  }
1173
1363
 
1174
1364
  return skills.length;
@@ -1211,16 +1401,30 @@ acquireLock();
1211
1401
  try {
1212
1402
  if (projectDir) {
1213
1403
  syncClaude(entries, projectDir);
1404
+ syncCodex(entries, projectDir);
1405
+ syncCopilot(entries, projectDir);
1406
+ const opencodeOk = syncOpencode(entries, projectDir);
1407
+ const vscodeOk = syncVSCode(projectDir);
1408
+ const cursorOk = syncCursor(projectDir);
1214
1409
  const cmdCount = syncCommands(projectDir);
1410
+ const skillCount = syncSkills(projectDir);
1411
+
1215
1412
  if (DRY_RUN) {
1216
1413
  printDryRunDiff();
1217
1414
  } else {
1218
1415
  commitStaging();
1219
- console.log(`Synced ${entries.length} agents + ${cmdCount} commands to ${path.join(projectDir, ".claude/")} (project mode).`);
1416
+ const targets = [
1417
+ "Claude Code",
1418
+ "Codex",
1419
+ "Copilot",
1420
+ opencodeOk && "OpenCode",
1421
+ vscodeOk && "VS Code",
1422
+ cursorOk && "Cursor",
1423
+ ].filter(Boolean).join(", ");
1424
+ console.log(`Synced ${entries.length} agents + ${cmdCount} commands + ${skillCount} skills to ${path.relative(process.cwd(), projectDir) || "."} (project mode → ${targets}).`);
1220
1425
  }
1221
1426
  } else {
1222
1427
  const personaCount = entries.filter((e) => e.isOrchestrator).length;
1223
- const specialistCount = entries.filter((e) => !e.isOrchestrator).length;
1224
1428
 
1225
1429
  syncCodex(entries);
1226
1430
  syncClaude(entries);
@@ -1228,14 +1432,13 @@ try {
1228
1432
  const opencodeOk = syncOpencode(entries);
1229
1433
  const vscodeOk = syncVSCode();
1230
1434
  const cursorOk = syncCursor();
1231
- const cmdCount = syncCommands();
1232
- const skillCount = syncSkills();
1435
+ syncCommands();
1436
+ syncSkills();
1233
1437
 
1234
1438
  if (DRY_RUN) {
1235
1439
  printDryRunDiff();
1236
1440
  } else {
1237
1441
  commitStaging();
1238
-
1239
1442
  const targets = [
1240
1443
  "Codex",
1241
1444
  "Claude Code",
@@ -1244,9 +1447,8 @@ try {
1244
1447
  vscodeOk && "VS Code",
1245
1448
  cursorOk && "Cursor",
1246
1449
  ].filter(Boolean).join(", ");
1247
- console.log(`Synced ${personaCount} orchestrator + ${specialistCount} specialists + ${cmdCount} commands + ${skillCount} skills to ${targets}.`);
1450
+ console.log(`Synced ${personaCount} front-door agent to global scope (${targets}). Specialists, commands, and skills are project-only — run \`construct init\` inside a project to scaffold them.`);
1248
1451
 
1249
- // Regenerate shell completions so new commands are immediately tab-completable
1250
1452
  const completionsDir = generateCompletions();
1251
1453
  if (completionsDir) {
1252
1454
  console.log(`Completions updated → ${completionsDir}`);