@geraldmaron/construct 1.0.12 → 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 (88) hide show
  1. package/README.md +8 -2
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +11 -7
  5. package/lib/dashboard-static.mjs +7 -4
  6. package/lib/doc-stamp.mjs +16 -0
  7. package/lib/docs-verify.mjs +1 -8
  8. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  9. package/lib/embed/daemon.mjs +20 -0
  10. package/lib/embed/docs-lifecycle.mjs +19 -0
  11. package/lib/embed/inbox.mjs +85 -2
  12. package/lib/embed/recommendation-store.mjs +29 -0
  13. package/lib/gates-audit.mjs +18 -12
  14. package/lib/hooks/_lib/input.mjs +52 -0
  15. package/lib/hooks/adaptive-lint.mjs +4 -0
  16. package/lib/hooks/agent-tracker.mjs +59 -15
  17. package/lib/hooks/audit-reads.mjs +83 -42
  18. package/lib/hooks/audit-trail.mjs +28 -18
  19. package/lib/hooks/bash-output-logger.mjs +8 -2
  20. package/lib/hooks/block-no-verify.mjs +4 -0
  21. package/lib/hooks/ci-status-check.mjs +4 -0
  22. package/lib/hooks/comment-lint.mjs +6 -4
  23. package/lib/hooks/config-protection.mjs +4 -0
  24. package/lib/hooks/context-watch.mjs +4 -0
  25. package/lib/hooks/context-window-recovery.mjs +4 -0
  26. package/lib/hooks/dep-audit.mjs +12 -5
  27. package/lib/hooks/doc-coupling-check.mjs +5 -1
  28. package/lib/hooks/edit-accumulator.mjs +25 -10
  29. package/lib/hooks/edit-error-recovery.mjs +4 -0
  30. package/lib/hooks/edit-guard.mjs +4 -0
  31. package/lib/hooks/guard-bash.mjs +4 -0
  32. package/lib/hooks/mcp-audit.mjs +4 -0
  33. package/lib/hooks/mcp-health-check.mjs +4 -0
  34. package/lib/hooks/model-fallback.mjs +7 -11
  35. package/lib/hooks/policy-engine.mjs +4 -0
  36. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  37. package/lib/hooks/post-merge-tracking.mjs +82 -0
  38. package/lib/hooks/pre-compact.mjs +4 -0
  39. package/lib/hooks/pre-push-gate.mjs +84 -231
  40. package/lib/hooks/proactive-activation.mjs +5 -2
  41. package/lib/hooks/readme-age-check.mjs +4 -0
  42. package/lib/hooks/registry-sync.mjs +35 -20
  43. package/lib/hooks/rule-verifier.mjs +3 -0
  44. package/lib/hooks/scan-secrets.mjs +4 -0
  45. package/lib/hooks/session-optimize.mjs +4 -0
  46. package/lib/hooks/session-reflect.mjs +4 -0
  47. package/lib/hooks/session-start.mjs +48 -1
  48. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  49. package/lib/hooks/stop-notify.mjs +13 -2
  50. package/lib/hooks/stop-typecheck.mjs +4 -0
  51. package/lib/hooks/test-watch.mjs +8 -4
  52. package/lib/init-unified.mjs +90 -23
  53. package/lib/intake/attribution.mjs +77 -0
  54. package/lib/intake/intake-config.mjs +3 -0
  55. package/lib/intake/manifest.mjs +107 -0
  56. package/lib/intake/poll-lock.mjs +136 -0
  57. package/lib/intake/prepare.mjs +42 -4
  58. package/lib/logging/rotate.mjs +394 -0
  59. package/lib/mcp/tools/project.mjs +2 -2
  60. package/lib/mcp/tools/telemetry.mjs +1 -1
  61. package/lib/opencode-config.mjs +10 -3
  62. package/lib/orchestration/routing-tables.mjs +176 -0
  63. package/lib/orchestration-policy.mjs +18 -106
  64. package/lib/parity.mjs +48 -7
  65. package/lib/profiles/lifecycle.mjs +19 -1
  66. package/lib/project-init-shared.mjs +11 -5
  67. package/lib/project-root.mjs +104 -0
  68. package/lib/roles/catalog.mjs +1 -1
  69. package/lib/roles/event-bus.mjs +29 -9
  70. package/lib/roles/router.mjs +8 -7
  71. package/lib/server/index.mjs +31 -9
  72. package/lib/server/static/index.html +1 -15
  73. package/lib/specialist-contracts-enforce.mjs +24 -10
  74. package/lib/status.mjs +1 -1
  75. package/lib/storage/backup.mjs +2 -2
  76. package/lib/telemetry/intent-verifications.mjs +16 -3
  77. package/lib/telemetry/skill-calls.mjs +12 -3
  78. package/lib/tracking-surfaces.mjs +375 -0
  79. package/lib/worker/trace.mjs +19 -2
  80. package/package.json +6 -2
  81. package/platforms/claude/settings.template.json +28 -27
  82. package/scripts/sync-specialists.mjs +269 -75
  83. package/specialists/registry.json +158 -13
  84. package/lib/hooks/env-check.mjs +0 -83
  85. package/lib/hooks/read-tracker.mjs +0 -61
  86. package/lib/policy/unified-gates.mjs +0 -96
  87. package/lib/server/static/assets/index-ab25c707.js +0 -70
  88. 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.
@@ -139,10 +153,36 @@ if (validationErrors.length > 0) {
139
153
 
140
154
  const DRY_RUN = process.argv.includes("--dry-run");
141
155
  const COMPRESS_PERSONAS = process.argv.includes("--compress-personas");
142
- 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;
143
175
  const lockPath = path.join(projectDir || root, ".cx", "sync.lock");
144
176
  const stagingDir = path.join(projectDir || root, ".cx", "sync-staging");
145
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
+
146
186
  /** Acquire an exclusive lockfile. Aborts if already held by a live process. */
147
187
  function acquireLock() {
148
188
  if (DRY_RUN) return;
@@ -498,6 +538,7 @@ function removeStaleAdapters(dir, ext, entries) {
498
538
  }
499
539
 
500
540
  // Stale manifest entries — delete files not in the current expected set.
541
+
501
542
  const previouslyWritten = readManifest(dir);
502
543
  for (const file of previouslyWritten) {
503
544
  if (!expected.has(file) && fs.existsSync(path.join(dir, file))) {
@@ -508,6 +549,28 @@ function removeStaleAdapters(dir, ext, entries) {
508
549
  writeManifest(dir, expected);
509
550
  }
510
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
+
511
574
  // --- Unified entry list: personas + agents ---
512
575
 
513
576
  function buildEntries() {
@@ -648,12 +711,21 @@ function syncClaude(entries, targetDir = null) {
648
711
  : path.join(home, ".claude", "agents");
649
712
  if (!DRY_RUN) mkdirp(claudeAgentsDir);
650
713
 
651
- 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) {
652
721
  const name = adapterName(entry);
653
722
  const md = claudeAgentMarkdown(entry, entries);
654
723
  writeFile(path.join(claudeAgentsDir, `${name}.md`), md, { stamp: false });
655
724
  }
656
- removeStaleAdapters(claudeAgentsDir, ".md", entries);
725
+ removeStaleAdapters(claudeAgentsDir, ".md", writeEntries);
726
+ if (!targetDir) {
727
+ sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", writeEntries.map((e) => `${adapterName(e)}.md`));
728
+ }
657
729
 
658
730
  if (targetDir) {
659
731
  writeProjectClaudeSettings(targetDir);
@@ -664,15 +736,13 @@ function syncClaude(entries, targetDir = null) {
664
736
  const claudeMdPath = path.join(home, ".claude", "CLAUDE.md");
665
737
  const existing = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, "utf8") : "# Claude Global Instructions\n";
666
738
  const personaList = entries.filter((e) => e.isOrchestrator).map((e) => `- \`${adapterName(e)}\`: ${e.role} — ${e.description}`).join("\n");
667
- // Only surface non-internal specialists in CLAUDE.md — internal specialists are dispatched by Construct, not invoked by users
668
- const specialistList = entries.filter((e) => !e.isOrchestrator && !e.internal).map((e) => `- \`${adapterName(e)}\`: ${e.description}`).join("\n");
669
739
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Personas
670
740
 
671
741
  ${personaList}
672
742
 
673
743
  ## Internal Specialists
674
744
 
675
- ${specialistList || "(all specialists are internal — routed through Construct)"}`;
745
+ (all specialists are internal — routed through Construct, available inside a Construct-initialized project)`;
676
746
  // User-managed file with our managed-block carved out — never doc-stamp.
677
747
  writeFile(claudeMdPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
678
748
 
@@ -745,19 +815,28 @@ function isCodexMcpSupported() {
745
815
  return true;
746
816
  }
747
817
 
748
- function syncCodex(entries) {
749
- 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");
750
822
  const codexAgentsDir = path.join(codexDir, "agents");
751
823
  if (!DRY_RUN) mkdirp(codexAgentsDir);
752
824
 
753
- for (const entry of entries) {
825
+ const writeEntries = targetDir ? entries : globalEntries(entries);
826
+
827
+ for (const entry of writeEntries) {
754
828
  writeFile(path.join(codexAgentsDir, `${adapterName(entry)}.toml`), codexAgentToml(entry, entries));
755
829
  }
756
- removeStaleAdapters(codexAgentsDir, ".toml", entries);
830
+ removeStaleAdapters(codexAgentsDir, ".toml", writeEntries);
831
+ if (!targetDir) {
832
+ sweepLegacyPrefixedFiles(codexAgentsDir, ".toml", writeEntries.map((e) => `${adapterName(e)}.toml`));
833
+ }
757
834
 
758
- const configPath = getCodexConfigPath(home);
835
+ const configPath = targetDir
836
+ ? path.join(codexDir, "config.toml")
837
+ : getCodexConfigPath(home);
759
838
  const existing = removeDanglingConstructMcpMarkers(removeDanglingConstructMcpTimeouts(readCodexConfig(configPath)));
760
- const entryNames = entries.map(adapterName);
839
+ const entryNames = writeEntries.map(adapterName);
761
840
  const registryMcp = registry.mcpServers ?? {};
762
841
  const existingMcpIds = Object.keys(registryMcp).filter((id) => hasCodexMcpTable(existing, id));
763
842
  const mcpIds = existingMcpIds.filter(isCodexMcpSupported);
@@ -767,8 +846,13 @@ function syncCodex(entries) {
767
846
  ));
768
847
  const hasAgentsRoot = /^\[agents\]\s*$/m.test(withoutManagedTables);
769
848
  const rootBlock = hasAgentsRoot ? "" : "[agents]\nmax_threads = 6\nmax_depth = 1\n\n";
770
- // Only expose non-internal agents in Codex config; internal agents are dispatched by Construct
771
- 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)}]
772
856
  description = ${tomlString(e.description)}
773
857
  config_file = ${tomlString(`agents/${adapterName(e)}.toml`)}
774
858
  `).join("\n");
@@ -804,25 +888,43 @@ When using this prompt, stay within the role above and adapt to the current repo
804
888
  `;
805
889
  }
806
890
 
807
- function syncCopilot(entries) {
808
- 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");
809
895
  if (!DRY_RUN) mkdirp(promptsDir);
810
- for (const entry of entries) {
896
+
897
+ const writeEntries = targetDir ? entries : globalEntries(entries);
898
+
899
+ for (const entry of writeEntries) {
811
900
  writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
812
901
  }
813
- removeStaleAdapters(promptsDir, ".prompt.md", entries);
902
+ removeStaleAdapters(promptsDir, ".prompt.md", writeEntries);
903
+ if (!targetDir) {
904
+ sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
905
+ }
814
906
 
815
- 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");
816
910
  const existing = fs.existsSync(instructionsPath)
817
911
  ? fs.readFileSync(instructionsPath, "utf8")
818
912
  : "# GitHub Copilot Instructions\n";
819
- 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");
820
920
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
821
921
 
822
922
  Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
823
923
 
824
- ${list || "(all specialists are internal — use construct for all tasks)"}`;
825
- // User-managed file with our managed-block carved out — never doc-stamp.
924
+ ${list || "(no front-door prompts to surface)"}`;
925
+
926
+ // User-managed file with the managed block carved out — never doc-stamp.
927
+
826
928
  writeFile(instructionsPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
827
929
  }
828
930
 
@@ -851,13 +953,41 @@ function getVSCodeSettingsPaths() {
851
953
  return candidates.filter(fs.existsSync);
852
954
  }
853
955
 
854
- function syncVSCode() {
855
- const settingsPaths = getVSCodeSettingsPaths();
856
- if (settingsPaths.length === 0) return false;
857
-
956
+ function syncVSCode(targetDir = null) {
858
957
  const registryMcp = registry.mcpServers ?? {};
859
958
  if (Object.keys(registryMcp).length === 0) return false;
860
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;
861
991
  let synced = false;
862
992
  for (const settingsPath of settingsPaths) {
863
993
  try {
@@ -877,40 +1007,64 @@ function syncVSCode() {
877
1007
  settings["github.copilot.mcpServers"] = mcpServers;
878
1008
  if (!DRY_RUN) fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
879
1009
  synced = true;
880
- } catch {
881
- // Skip unreadable settings files
882
- }
1010
+ } catch { /* unreadable settings file */ }
883
1011
  }
884
1012
  return synced;
885
1013
  }
886
1014
 
887
1015
  // --- Cursor adapter ---
888
1016
 
889
- function syncCursor() {
890
- const cursorMcpPath = path.join(home, ".cursor", "mcp.json");
891
- if (!fs.existsSync(cursorMcpPath)) return false;
892
-
1017
+ function syncCursor(targetDir = null) {
893
1018
  const registryMcp = registry.mcpServers ?? {};
894
1019
  if (Object.keys(registryMcp).length === 0) return false;
895
1020
 
896
- try {
897
- const config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8"));
898
- if (!config.mcpServers) config.mcpServers = {};
899
- for (const [id, mcpDef] of Object.entries(registryMcp)) {
900
- const existingEntry = config.mcpServers[id];
901
- const existing = JSON.stringify(existingEntry ?? "");
902
- const hasPlaceholder = existing.includes("__");
903
- const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
904
- const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
905
- const transportMismatch = registryWantsCommand && existingIsRemote;
906
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
907
- 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
+ }
908
1065
  }
909
- if (!DRY_RUN) fs.writeFileSync(cursorMcpPath, JSON.stringify(config, null, 2) + "\n");
910
- return true;
911
- } catch {
912
- return false;
913
1066
  }
1067
+ return true;
914
1068
  }
915
1069
 
916
1070
  // --- OpenCode adapter ---
@@ -931,14 +1085,28 @@ function opencodeTaskPermissions(entry) {
931
1085
  };
932
1086
  }
933
1087
 
934
- function syncOpencode(entries) {
935
- const configPath = findOpenCodeConfigPath();
936
- if (!fs.existsSync(configPath)) return false;
937
- 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");
938
1102
  const managedPluginPath = path.join(pluginsDir, "construct-fallback.js");
939
1103
  const toolkitPluginPath = path.join(root, "platforms", "opencode", "plugins", "construct-fallback.js");
940
1104
 
941
- 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();
942
1110
  if (!config.agent) config.agent = {};
943
1111
  if (!Array.isArray(config.plugin)) config.plugin = [];
944
1112
  config.plugin = config.plugin.filter((entry) => {
@@ -1022,18 +1190,21 @@ function syncOpencode(entries) {
1022
1190
  }
1023
1191
  }
1024
1192
 
1025
- // 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
+
1026
1196
  const prefixes = [agentPrefix];
1027
1197
  for (const key of Object.keys(config.agent)) {
1028
1198
  const isManaged = prefixes.some((p) => key.startsWith(p));
1029
1199
  const isOrchestrator = registry.orchestrator?.name === key;
1030
- if ((isManaged || isOrchestrator) && !entries.find((e) => adapterName(e) === key)) {
1200
+ if ((isManaged || isOrchestrator) && !writeEntries.find((e) => adapterName(e) === key)) {
1031
1201
  delete config.agent[key];
1032
1202
  }
1033
1203
  }
1034
1204
 
1035
- // Write agents — no model/modelFallback set; agents inherit the global model
1036
- for (const entry of entries) {
1205
+ // Write agents — no model/modelFallback set; agents inherit the global model.
1206
+
1207
+ for (const entry of writeEntries) {
1037
1208
  const name = adapterName(entry);
1038
1209
  const perms = opencodePermissions(entry);
1039
1210
  config.agent[name] = {
@@ -1072,12 +1243,16 @@ function syncOpencode(entries) {
1072
1243
  // --- Slash commands adapter ---
1073
1244
 
1074
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
+
1075
1252
  const sourceCommandsDir = path.join(root, "commands");
1076
1253
  if (!fs.existsSync(sourceCommandsDir)) return 0;
1077
1254
 
1078
- const claudeCommandsDir = targetDir
1079
- ? path.join(targetDir, ".claude", "commands")
1080
- : path.join(home, ".claude", "commands");
1255
+ const claudeCommandsDir = path.join(targetDir, ".claude", "commands");
1081
1256
 
1082
1257
  let count = 0;
1083
1258
  for (const domain of fs.readdirSync(sourceCommandsDir, { withFileTypes: true })) {
@@ -1162,21 +1337,28 @@ function collectSkills() {
1162
1337
  * — a doc-stamp YAML block before the real frontmatter produces double-
1163
1338
  * frontmatter the loader can't parse.
1164
1339
  */
1165
- 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
+
1166
1348
  const skills = collectSkills();
1167
1349
  if (skills.length === 0) return 0;
1168
1350
 
1169
- const claudeSkillsDir = path.join(home, ".claude", "skills");
1170
- const agentsSkillsDir = path.join(home, ".agents", "skills");
1351
+ const claudeSkillsDir = path.join(targetDir, ".claude", "skills");
1171
1352
 
1172
1353
  for (const { name, content } of skills) {
1173
1354
  const frontmatter = buildSkillFrontmatter(name, content);
1355
+
1174
1356
  // Strip any existing frontmatter from the source body so we don't emit two
1175
1357
  // blocks if a hand-authored skill already carries one.
1358
+
1176
1359
  const body = stripLeadingFrontmatter(content);
1177
1360
  const generated = `${frontmatter}\n${body}`;
1178
1361
  writeFile(path.join(claudeSkillsDir, name, "SKILL.md"), generated, { stamp: false });
1179
- writeFile(path.join(agentsSkillsDir, name, "SKILL.md"), generated, { stamp: false });
1180
1362
  }
1181
1363
 
1182
1364
  return skills.length;
@@ -1219,16 +1401,30 @@ acquireLock();
1219
1401
  try {
1220
1402
  if (projectDir) {
1221
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);
1222
1409
  const cmdCount = syncCommands(projectDir);
1410
+ const skillCount = syncSkills(projectDir);
1411
+
1223
1412
  if (DRY_RUN) {
1224
1413
  printDryRunDiff();
1225
1414
  } else {
1226
1415
  commitStaging();
1227
- 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}).`);
1228
1425
  }
1229
1426
  } else {
1230
1427
  const personaCount = entries.filter((e) => e.isOrchestrator).length;
1231
- const specialistCount = entries.filter((e) => !e.isOrchestrator).length;
1232
1428
 
1233
1429
  syncCodex(entries);
1234
1430
  syncClaude(entries);
@@ -1236,14 +1432,13 @@ try {
1236
1432
  const opencodeOk = syncOpencode(entries);
1237
1433
  const vscodeOk = syncVSCode();
1238
1434
  const cursorOk = syncCursor();
1239
- const cmdCount = syncCommands();
1240
- const skillCount = syncSkills();
1435
+ syncCommands();
1436
+ syncSkills();
1241
1437
 
1242
1438
  if (DRY_RUN) {
1243
1439
  printDryRunDiff();
1244
1440
  } else {
1245
1441
  commitStaging();
1246
-
1247
1442
  const targets = [
1248
1443
  "Codex",
1249
1444
  "Claude Code",
@@ -1252,9 +1447,8 @@ try {
1252
1447
  vscodeOk && "VS Code",
1253
1448
  cursorOk && "Cursor",
1254
1449
  ].filter(Boolean).join(", ");
1255
- 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.`);
1256
1451
 
1257
- // Regenerate shell completions so new commands are immediately tab-completable
1258
1452
  const completionsDir = generateCompletions();
1259
1453
  if (completionsDir) {
1260
1454
  console.log(`Completions updated → ${completionsDir}`);