@geraldmaron/construct 1.0.21 → 1.0.24

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 (150) hide show
  1. package/README.md +23 -7
  2. package/bin/construct +206 -228
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +31 -10
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/features.mjs +11 -11
  20. package/lib/git-hooks-path.mjs +61 -0
  21. package/lib/home-namespace.mjs +60 -0
  22. package/lib/hooks/ci-status-check.mjs +62 -40
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/session-reflect.mjs +5 -1
  27. package/lib/hooks/stop-notify.mjs +32 -17
  28. package/lib/hooks/stop-typecheck.mjs +7 -2
  29. package/lib/host-capabilities.mjs +24 -8
  30. package/lib/host-disposition.mjs +76 -0
  31. package/lib/ingest/provider-extract.mjs +1 -1
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intake/git-queue.mjs +195 -0
  38. package/lib/intake/queue.mjs +9 -16
  39. package/lib/intent-classifier.mjs +1 -1
  40. package/lib/knowledge/search.mjs +52 -3
  41. package/lib/mcp/server.mjs +57 -14
  42. package/lib/mcp/tools/memory.mjs +2 -2
  43. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  44. package/lib/mcp/tools/storage.mjs +2 -3
  45. package/lib/mcp-catalog.json +3 -3
  46. package/lib/mcp-manager.mjs +59 -3
  47. package/lib/model-registry.mjs +40 -33
  48. package/lib/observation-store.mjs +39 -163
  49. package/lib/opencode-config.mjs +1 -1
  50. package/lib/orchestration/events.mjs +66 -0
  51. package/lib/orchestration/runtime.mjs +72 -10
  52. package/lib/orchestration/worker.mjs +69 -21
  53. package/lib/orchestration-policy.mjs +27 -3
  54. package/lib/parity.mjs +46 -24
  55. package/lib/policy/unified-gates.mjs +96 -0
  56. package/lib/project-init-shared.mjs +0 -173
  57. package/lib/reconcile/adapter-prune.mjs +105 -0
  58. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  59. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  60. package/lib/reconcile/index.mjs +169 -0
  61. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  62. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  63. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  64. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  65. package/lib/runtime/uv-bootstrap.mjs +27 -3
  66. package/lib/schema-infer.mjs +16 -2
  67. package/lib/server/csrf.mjs +14 -2
  68. package/lib/server/index.mjs +95 -0
  69. package/lib/service-manager.mjs +59 -252
  70. package/lib/setup-prompts.mjs +2 -1
  71. package/lib/setup.mjs +104 -480
  72. package/lib/status.mjs +3 -6
  73. package/lib/storage/admin.mjs +48 -325
  74. package/lib/storage/backend.mjs +10 -43
  75. package/lib/storage/hybrid-query.mjs +15 -196
  76. package/lib/storage/sync.mjs +36 -177
  77. package/lib/storage/vector-client.mjs +256 -235
  78. package/lib/strategy-store.mjs +35 -286
  79. package/lib/template-registry.mjs +73 -0
  80. package/lib/term-format.mjs +75 -0
  81. package/lib/uninstall/uninstall.mjs +180 -7
  82. package/lib/worker/entrypoint.mjs +6 -14
  83. package/package.json +7 -6
  84. package/personas/construct.md +7 -8
  85. package/platforms/claude/settings.template.json +30 -11
  86. package/platforms/opencode/config.template.json +2 -2
  87. package/rules/common/neurodivergent-output.md +66 -0
  88. package/rules/common/tool-invisibility.md +37 -0
  89. package/scripts/sync-specialists.mjs +427 -107
  90. package/skills/operating/orchestration-reference.md +2 -16
  91. package/specialists/policy-inventory.json +14 -0
  92. package/specialists/prompts/cx-accessibility.md +2 -6
  93. package/specialists/prompts/cx-ai-engineer.md +0 -4
  94. package/specialists/prompts/cx-architect.md +3 -5
  95. package/specialists/prompts/cx-business-strategist.md +0 -5
  96. package/specialists/prompts/cx-data-analyst.md +0 -4
  97. package/specialists/prompts/cx-data-engineer.md +0 -4
  98. package/specialists/prompts/cx-debugger.md +2 -6
  99. package/specialists/prompts/cx-designer.md +0 -8
  100. package/specialists/prompts/cx-devil-advocate.md +2 -2
  101. package/specialists/prompts/cx-docs-keeper.md +0 -13
  102. package/specialists/prompts/cx-engineer.md +0 -13
  103. package/specialists/prompts/cx-evaluator.md +2 -2
  104. package/specialists/prompts/cx-explorer.md +4 -5
  105. package/specialists/prompts/cx-legal-compliance.md +4 -5
  106. package/specialists/prompts/cx-operations.md +0 -5
  107. package/specialists/prompts/cx-orchestrator.md +0 -4
  108. package/specialists/prompts/cx-platform-engineer.md +0 -8
  109. package/specialists/prompts/cx-product-manager.md +0 -8
  110. package/specialists/prompts/cx-qa.md +3 -12
  111. package/specialists/prompts/cx-rd-lead.md +0 -5
  112. package/specialists/prompts/cx-release-manager.md +0 -8
  113. package/specialists/prompts/cx-researcher.md +5 -29
  114. package/specialists/prompts/cx-reviewer.md +2 -6
  115. package/specialists/prompts/cx-security.md +2 -11
  116. package/specialists/prompts/cx-sre.md +0 -15
  117. package/specialists/prompts/cx-test-automation.md +0 -4
  118. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  119. package/specialists/prompts/cx-ux-researcher.md +0 -4
  120. package/specialists/registry.json +28 -36
  121. package/templates/distribution/run.mjs +36 -7
  122. package/templates/docs/accessibility-audit.md +56 -0
  123. package/templates/docs/architecture-review.md +59 -0
  124. package/templates/docs/code-review-report.md +46 -0
  125. package/templates/docs/construct_guide.md +15 -15
  126. package/templates/docs/debug-investigation.md +53 -0
  127. package/templates/docs/qa-report.md +48 -0
  128. package/templates/docs/security-audit-report.md +48 -0
  129. package/templates/docs/task-packet.md +49 -0
  130. package/templates/docs/verdict.md +40 -0
  131. package/db/schema/001_init.sql +0 -40
  132. package/db/schema/002_pgvector.sql +0 -182
  133. package/db/schema/003_intake.sql +0 -47
  134. package/db/schema/003_observation_reconciliation.sql +0 -14
  135. package/db/schema/004_recommendations.sql +0 -46
  136. package/db/schema/005_strategy.sql +0 -21
  137. package/db/schema/006_graph.sql +0 -24
  138. package/db/schema/007_tags.sql +0 -30
  139. package/db/schema/008_skill_usage.sql +0 -24
  140. package/db/schema/009_scheduler.sql +0 -14
  141. package/db/schema/010_cx_scores.sql +0 -51
  142. package/lib/intake/postgres-queue.mjs +0 -240
  143. package/lib/server/static/index.html +0 -1
  144. package/lib/services/local-postgres.mjs +0 -15
  145. package/lib/storage/backup.mjs +0 -347
  146. package/lib/storage/migrations.mjs +0 -187
  147. package/lib/storage/postgres-backup.mjs +0 -124
  148. package/lib/storage/sql-store.mjs +0 -45
  149. package/lib/storage/store-version.mjs +0 -115
  150. package/lib/storage/vector-store.mjs +0 -100
@@ -52,6 +52,7 @@ import {
52
52
  } from "../lib/mcp-platform-config.mjs";
53
53
  import { loadConstructEnv } from "../lib/env-config.mjs";
54
54
  import { inlineRoleAntiPatterns, PROMPT_WORD_CAP } from "../lib/role-preload.mjs";
55
+ import { loadManifest } from "../lib/roles/manifest.mjs";
55
56
  import { resolveActiveProfile } from "../lib/profiles/loader.mjs";
56
57
  import { resolveTiersForPrimary } from "../lib/model-router.mjs";
57
58
  import { stampFrontmatter } from "../lib/doc-stamp.mjs";
@@ -156,6 +157,67 @@ const COMPRESS_PERSONAS = process.argv.includes("--compress-personas");
156
157
  const PROJECT_FLAG = process.argv.includes("--project");
157
158
  const GLOBAL_FLAG = process.argv.includes("--global");
158
159
 
160
+ // --quiet suppresses only the closing one-line summary, not the work or any
161
+ // warning. `construct install` runs the global tier twice (plain `sync` then
162
+ // `sync --global`); in a non-project cwd both land in the same global branch and
163
+ // print the identical "Synced … to global scope" + "Completions updated" lines.
164
+ // Passing --quiet to the first call lets the canonical summary print exactly once
165
+ // from `sync --global`, with no change to what either call writes.
166
+
167
+ const QUIET = process.argv.includes("--quiet") || process.argv.includes("-q");
168
+ const summary = (msg) => { if (!QUIET) console.log(msg); };
169
+
170
+ // Project-tier host selection. `--hosts=claude,codex,…` (or CONSTRUCT_SYNC_HOSTS)
171
+ // restricts which adapter sets the project tier writes, so `construct init` can
172
+ // scaffold only the hosts the user actually has (construct-4xy6 / ADR-0027 §1).
173
+ // Absent → null → write every host, preserving `construct sync` back-compat.
174
+
175
+ import { detectHostCapabilities } from "../lib/host-capabilities.mjs";
176
+
177
+ const HOST_KEYS = ["claude", "codex", "copilot", "opencode", "vscode", "cursor"];
178
+
179
+ function parseHostSelection() {
180
+ const arg = process.argv.find((a) => a.startsWith("--hosts="));
181
+ const raw = arg ? arg.slice("--hosts=".length) : process.env.CONSTRUCT_SYNC_HOSTS;
182
+ if (!raw) {
183
+ // Default to detected hosts if none are explicitly requested.
184
+ const detected = new Set();
185
+ const nameToKey = {
186
+ "Claude Code": "claude",
187
+ "OpenCode": "opencode",
188
+ "Codex": "codex",
189
+ "VS Code": "vscode",
190
+ "Cursor": "cursor",
191
+ "Copilot": "copilot",
192
+ };
193
+ try {
194
+ for (const cap of detectHostCapabilities()) {
195
+ if (cap.availability === "installed" && nameToKey[cap.host]) {
196
+ detected.add(nameToKey[cap.host]);
197
+ }
198
+ }
199
+ } catch { /* detection is advisory */ }
200
+
201
+ // Config file present means the user has (or had) OpenCode — include it
202
+ // so the sync writes to the existing config rather than pruning it.
203
+ // Binary-based detection misses non-PATH installs and CI-runner setups.
204
+ if (!detected.has("opencode")) {
205
+ try {
206
+ if (fs.existsSync(findOpenCodeConfigPath())) detected.add("opencode");
207
+ } catch { /* advisory */ }
208
+ }
209
+
210
+ // Always include Claude as the baseline if nothing else is detected.
211
+ if (detected.size === 0) detected.add("claude");
212
+ return detected;
213
+ }
214
+ const wanted = new Set(raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
215
+ return new Set(HOST_KEYS.filter((k) => wanted.has(k)));
216
+ }
217
+
218
+ const HOST_SELECTION = parseHostSelection();
219
+ const wantsHost = (key) => HOST_SELECTION.has(key);
220
+
159
221
  /**
160
222
  * A Construct project carries `.construct/` (the launcher staged by
161
223
  * `stage-project.mjs`) or `.cx/` (state). When `construct sync` runs inside
@@ -469,6 +531,64 @@ function buildRoleFooter(entry) {
469
531
  return `\n\n${lines.join("\n")}`;
470
532
  }
471
533
 
534
+ // Fence + handoff data is the source of truth in specialists/role-manifests.json
535
+ // (events, fence.allowedPaths, fence.allowedBdLabels, fence.approvalRequired,
536
+ // handoffCandidates, outputs.docTypes). Restating that JSON inside each
537
+ // specialist prompt would invite drift across 28 files; the renderer below
538
+ // emits the section from the manifest entry so the JSON is the only authority.
539
+ //
540
+ // Returns the empty string for an unonboarded persona (no manifest entry, or
541
+ // an empty fence block). buildPrompt appends the result verbatim, so the
542
+ // returned string carries its own leading separator.
543
+
544
+ export function renderRoleFrameworkSection(entry) {
545
+ const personaName = String(entry?.name || "").replace(/^cx-/, "");
546
+ if (!personaName) return "";
547
+ const manifest = loadManifest(personaName);
548
+ if (!manifest) return "";
549
+
550
+ const events = Array.isArray(manifest.events) ? manifest.events : [];
551
+ const fence = manifest.fence || {};
552
+ const allowedPaths = Array.isArray(fence.allowedPaths) ? fence.allowedPaths : [];
553
+ const allowedBdLabels = Array.isArray(fence.allowedBdLabels) ? fence.allowedBdLabels : [];
554
+ const approvalRequired = Array.isArray(fence.approvalRequired) ? fence.approvalRequired : [];
555
+ const handoffCandidates = Array.isArray(manifest.handoffCandidates) ? manifest.handoffCandidates : [];
556
+ const docTypes = Array.isArray(manifest.outputs?.docTypes) ? manifest.outputs.docTypes : [];
557
+
558
+ // Empty fence + empty events means the persona is reserved but not wired —
559
+ // no section to render rather than emit a misleading stub.
560
+
561
+ if (!events.length && !allowedPaths.length) return "";
562
+
563
+ const fmt = (xs) => xs.map((x) => `\`${x}\``).join(", ");
564
+ const eventList = events.length ? fmt(events) : "_handoff events_";
565
+ const pathList = allowedPaths.length ? fmt(allowedPaths) : "_none declared_";
566
+ const labelList = allowedBdLabels.length ? fmt(allowedBdLabels) : "_none declared_";
567
+ const approvalList = approvalRequired.length ? fmt(approvalRequired) : "_no approval gate declared_";
568
+ const docTypeList = docTypes.length ? fmt(docTypes) : "role-specific artifacts";
569
+ const handoffList = handoffCandidates.length
570
+ ? handoffCandidates.map((c) => `\`next:cx-${c}\``).join(", ")
571
+ : "";
572
+
573
+ const lines = [
574
+ "",
575
+ "## When invoked via the role framework",
576
+ "",
577
+ `Construct may dispatch you in response to ${eventList} events. A bd issue with the event payload exists when dispatched: read it first via \`bd show <id>\`.`,
578
+ "",
579
+ `**Fence** (source of truth: \`specialists/role-manifests.json\` → \`${personaName}\`):`,
580
+ `- Allowed paths: ${pathList}`,
581
+ `- Allowed bd labels: ${labelList}`,
582
+ `- Approval required: ${approvalList}`,
583
+ "",
584
+ `You may freely create, edit, and verify within the fence (allowed paths and labels above). You produce ${docTypeList}. You **must not** commit, push, or operate outside the fence without explicit user approval per \`rules/common/commit-approval.md\`.`,
585
+ ];
586
+ if (handoffList) {
587
+ lines.push("", `**Handoff syntax**: append a bd label of the form \`next:cx-<role>\`. Candidates from this role: ${handoffList}.`);
588
+ }
589
+ return `\n\n${lines.join("\n")}`;
590
+ }
591
+
472
592
  function buildPrompt(entry, allEntries, platform) {
473
593
  let prompt = resolvePromptContract(entry, {
474
594
  rootDir: root,
@@ -485,6 +605,8 @@ function buildPrompt(entry, allEntries, platform) {
485
605
 
486
606
  prompt += buildRoleFooter(entry);
487
607
 
608
+ prompt += renderRoleFrameworkSection(entry);
609
+
488
610
  const platformItems = platformGuidance[platform] ?? [];
489
611
  const allGuidance = [...sharedGuidance, ...platformItems];
490
612
  if (allGuidance.length > 0) {
@@ -678,6 +800,50 @@ function makeHooksPortable(hooksJson) {
678
800
  return JSON.stringify(walk(hooksJson));
679
801
  }
680
802
 
803
+ const GLOBAL_CLAUDE_HOOK_IDS = new Set([
804
+ 'pre:bash:block-no-verify',
805
+ 'pre:bash:guard-dangerous',
806
+ 'pre:edit:config-protection',
807
+ 'pre:edit-guard',
808
+ 'post:edit:json-validate',
809
+ 'post:edit:scan-secrets',
810
+ ]);
811
+
812
+ const GLOBAL_CLAUDE_MCP_IDS = new Set([
813
+ 'context7',
814
+ ]);
815
+
816
+ function filterGlobalClaudeHooks(hooksJson) {
817
+ const filtered = {};
818
+ for (const [event, groups] of Object.entries(hooksJson ?? {})) {
819
+ const kept = groups.filter((group) => GLOBAL_CLAUDE_HOOK_IDS.has(group.id));
820
+ if (kept.length > 0) filtered[event] = kept;
821
+ }
822
+ return filtered;
823
+ }
824
+
825
+ function syncGlobalClaudeMcpServers(settings, registryMcp) {
826
+ settings.mcpServers ??= {};
827
+ for (const id of Object.keys(settings.mcpServers)) {
828
+ if (id in registryMcp && !GLOBAL_CLAUDE_MCP_IDS.has(id)) {
829
+ delete settings.mcpServers[id];
830
+ }
831
+ }
832
+
833
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
834
+ if (!GLOBAL_CLAUDE_MCP_IDS.has(id)) continue;
835
+ const existingEntry = settings.mcpServers[id];
836
+ const existing = JSON.stringify(existingEntry ?? "");
837
+ const hasPlaceholder = existing.includes("__");
838
+ const hasFloatingVersion = existing.includes("@latest");
839
+ const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
840
+ const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
841
+ const transportMismatch = registryWantsCommand && existingIsRemote;
842
+ if (existingEntry && !hasPlaceholder && !hasFloatingVersion && !transportMismatch) continue;
843
+ settings.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
844
+ }
845
+ }
846
+
681
847
  /**
682
848
  * Materialise a project-local `.claude/settings.json` from the home template,
683
849
  * with hook commands rewritten to be path-relative to whatever Construct
@@ -709,22 +875,40 @@ function writeProjectClaudeSettings(targetDir) {
709
875
  }
710
876
  }
711
877
 
878
+ // The registry is the single source of truth for MCP servers; VS Code, Codex,
879
+ // and OpenCode all wire it. Project Claude must match — above all `construct-mcp`,
880
+ // the server exposing project_context/get_skill/get_template/orchestration_policy
881
+ // that the specialist loop depends on. The curated template omits it, which left
882
+ // Claude Code as the only selected tool without the construct config. Merge the
883
+ // registry on top of the template seed; existing entries win so user edits stick,
884
+ // and buildClaudeMcpEntry resolves each server's path/env.
885
+ const registryMcp = registry.mcpServers ?? {};
886
+ existing.mcpServers ??= {};
887
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
888
+ if (existing.mcpServers[id]) continue;
889
+ existing.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
890
+ }
891
+
712
892
  if (DRY_RUN) return;
713
893
  mkdirp(path.dirname(settingsPath));
714
894
  writeFile(settingsPath, JSON.stringify(existing, null, 2) + "\n");
715
895
  }
716
896
 
717
- function syncClaude(entries, targetDir = null) {
897
+ function syncClaude(entries, targetDir = null, wants = true) {
718
898
  const claudeAgentsDir = targetDir
719
899
  ? path.join(targetDir, ".claude", "agents")
720
900
  : path.join(home, ".claude", "agents");
721
- if (!DRY_RUN) mkdirp(claudeAgentsDir);
901
+ if (!DRY_RUN && wants) mkdirp(claudeAgentsDir);
722
902
 
723
- // Global scope ships only the `construct` front-door agent; specialists are
724
- // project content. removeStaleAdapters will sweep any cx-* files left over
725
- // from previous syncs because they fall out of the manifest set.
903
+ // Claude Code and VS Code both read the user-scope `~/.claude/agents/`, so a
904
+ // global front-door agent duplicates the project orchestrator in any editor
905
+ // that reads both scopes (the construct ×2 in the VS Code picker). Global
906
+ // scope therefore writes NO agent file — the project's own
907
+ // `.claude/agents/construct.md` is the front door; global hooks (settings.json)
908
+ // and CLAUDE.md still install. An empty write set sweeps any global agent.
909
+ // Both global and project scope now emit only the front door (Single Front Door).
726
910
 
727
- const writeEntries = targetDir ? entries : globalEntries(entries);
911
+ const writeEntries = (targetDir && wants) ? globalEntries(entries) : [];
728
912
 
729
913
  for (const entry of writeEntries) {
730
914
  const name = adapterName(entry);
@@ -733,7 +917,7 @@ function syncClaude(entries, targetDir = null) {
733
917
  }
734
918
  removeStaleAdapters(claudeAgentsDir, ".md", writeEntries);
735
919
  if (!targetDir) {
736
- sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", writeEntries.map((e) => `${adapterName(e)}.md`));
920
+ sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", []);
737
921
  }
738
922
 
739
923
  if (targetDir) {
@@ -768,23 +952,13 @@ ${personaList}
768
952
  const constructReal = (() => {
769
953
  try { return fs.realpathSync(path.join(home, ".construct")); } catch { return path.join(home, ".construct"); }
770
954
  })();
771
- const hookStr = JSON.stringify(template.hooks)
955
+ const hookStr = JSON.stringify(filterGlobalClaudeHooks(template.hooks))
772
956
  .replace(/\$HOME\/\.construct/g, constructReal.replace(/\\/g, "/"));
773
957
  settings.hooks = JSON.parse(hookStr);
774
958
  }
775
959
  }
776
- if (!settings.mcpServers) settings.mcpServers = {};
777
960
  const registryMcp = registry.mcpServers ?? {};
778
- for (const [id, mcpDef] of Object.entries(registryMcp)) {
779
- const existingEntry = settings.mcpServers[id];
780
- const existing = JSON.stringify(existingEntry ?? "");
781
- const hasPlaceholder = existing.includes("__");
782
- const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
783
- const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
784
- const transportMismatch = registryWantsCommand && existingIsRemote;
785
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
786
- settings.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
787
- }
961
+ syncGlobalClaudeMcpServers(settings, registryMcp);
788
962
  if (!DRY_RUN) fs.writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n");
789
963
  }
790
964
  }
@@ -824,14 +998,29 @@ function isCodexMcpSupported() {
824
998
  return true;
825
999
  }
826
1000
 
827
- function syncCodex(entries, targetDir = null) {
1001
+ // Codex aborts at startup when an MCP server's bearer_token_env_var names an env
1002
+ // var that is unset ("Environment variable GITHUB_TOKEN for MCP server github is
1003
+ // not set"). Unlike OpenCode — which keeps the `{env:VAR}` ref and resolves it at
1004
+ // runtime — Codex must have the credential at sync time, so an entry whose token
1005
+ // env var is unresolved is omitted from the Codex config (construct-n6h7).
1006
+ // Entries with no credential requirement always pass.
1007
+
1008
+ function codexMcpEnvResolves(id, def, env = process.env) {
1009
+ const entry = buildCodexMcpEntry(id, def, env);
1010
+ const tokenVar = entry?.bearer_token_env_var;
1011
+ if (!tokenVar) return true;
1012
+ const val = env[tokenVar];
1013
+ return val !== undefined && val !== "";
1014
+ }
1015
+
1016
+ function syncCodex(entries, targetDir = null, wants = true) {
828
1017
  const codexDir = targetDir
829
1018
  ? path.join(targetDir, ".codex")
830
1019
  : path.join(home, ".codex");
831
1020
  const codexAgentsDir = path.join(codexDir, "agents");
832
- if (!DRY_RUN) mkdirp(codexAgentsDir);
1021
+ if (!DRY_RUN && wants) mkdirp(codexAgentsDir);
833
1022
 
834
- const writeEntries = targetDir ? entries : globalEntries(entries);
1023
+ const writeEntries = wants ? globalEntries(entries) : [];
835
1024
 
836
1025
  for (const entry of writeEntries) {
837
1026
  writeFile(path.join(codexAgentsDir, `${adapterName(entry)}.toml`), codexAgentToml(entry, entries));
@@ -847,8 +1036,12 @@ function syncCodex(entries, targetDir = null) {
847
1036
  const existing = removeDanglingConstructMcpMarkers(removeDanglingConstructMcpTimeouts(readCodexConfig(configPath)));
848
1037
  const entryNames = writeEntries.map(adapterName);
849
1038
  const registryMcp = registry.mcpServers ?? {};
1039
+ // Seed every supported MCP server (parity with Claude/OpenCode/VS Code/Cursor),
1040
+ // not just tables already present — otherwise Codex never receives `construct-mcp`
1041
+ // and the orchestration tool is unreachable there. existingMcpIds still drives
1042
+ // cleanup of any pre-existing standalone tables so the managed block stays canonical.
1043
+ const mcpIds = Object.keys(registryMcp).filter((id) => isCodexMcpSupported() && codexMcpEnvResolves(id, registryMcp[id], process.env));
850
1044
  const existingMcpIds = Object.keys(registryMcp).filter((id) => hasCodexMcpTable(existing, id));
851
- const mcpIds = existingMcpIds.filter(isCodexMcpSupported);
852
1045
  const withoutManagedTables = removeDanglingConstructMcpMarkers(removeTomlTables(
853
1046
  removeCodexAgentTables(existing, entryNames),
854
1047
  existingMcpIds.flatMap((id) => [`mcp_servers.${id}`, `mcp_servers.${tomlString(id)}`]),
@@ -856,6 +1049,14 @@ function syncCodex(entries, targetDir = null) {
856
1049
  const hasAgentsRoot = /^\[agents\]\s*$/m.test(withoutManagedTables);
857
1050
  const rootBlock = hasAgentsRoot ? "" : "[agents]\nmax_threads = 6\nmax_depth = 1\n\n";
858
1051
 
1052
+ // Workspace-write agents (the orchestrator + canEdit specialists) need network
1053
+ // access for WebFetch/context7 — Codex's workspace-write sandbox blocks the
1054
+ // network unless this is set. Skip when the user already manages the table so
1055
+ // their own sandbox settings (e.g. writable_roots) are not clobbered.
1056
+
1057
+ const hasSandboxConfig = /^\[sandbox_workspace_write\]/m.test(withoutManagedTables);
1058
+ const sandboxBlock = hasSandboxConfig ? "" : "[sandbox_workspace_write]\nnetwork_access = true\n\n";
1059
+
859
1060
  // In project scope every entry is reachable from the user (Construct dispatches
860
1061
  // internal specialists itself, but project teammates may want to address them).
861
1062
  // In global scope only the `construct` front-door agent is registered.
@@ -869,7 +1070,7 @@ config_file = ${tomlString(`agents/${adapterName(e)}.toml`)}
869
1070
  const mcpBlock = mcpIds
870
1071
  .map((id) => serializeCodexMcpTable(id, buildCodexMcpEntry(id, registryMcp[id], process.env)))
871
1072
  .join("\n\n");
872
- const withAgents = replaceManagedBlock(withoutManagedTables, `${rootBlock}${blocks}`);
1073
+ const withAgents = replaceManagedBlock(withoutManagedTables, `${sandboxBlock}${rootBlock}${blocks}`);
873
1074
  writeCodexConfig(replaceManagedBlock(
874
1075
  withAgents,
875
1076
  mcpBlock,
@@ -897,13 +1098,13 @@ When using this prompt, stay within the role above and adapt to the current repo
897
1098
  `;
898
1099
  }
899
1100
 
900
- function syncCopilot(entries, targetDir = null) {
1101
+ function syncCopilot(entries, targetDir = null, wants = true) {
901
1102
  const promptsDir = targetDir
902
1103
  ? path.join(targetDir, ".github", "prompts")
903
1104
  : path.join(home, ".github", "prompts");
904
- if (!DRY_RUN) mkdirp(promptsDir);
1105
+ if (!DRY_RUN && wants) mkdirp(promptsDir);
905
1106
 
906
- const writeEntries = targetDir ? entries : globalEntries(entries);
1107
+ const writeEntries = wants ? globalEntries(entries) : [];
907
1108
 
908
1109
  for (const entry of writeEntries) {
909
1110
  writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
@@ -913,6 +1114,16 @@ function syncCopilot(entries, targetDir = null) {
913
1114
  sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
914
1115
  }
915
1116
 
1117
+ // VS Code / Copilot agent mode reads the project's `.claude/agents/*.md`
1118
+ // natively (Claude-format custom agents), so Construct does NOT write a
1119
+ // separate `.github/agents/` set — doing so duplicated every agent in the
1120
+ // picker. A pre-existing `.github/agents/` from an earlier sync is swept.
1121
+
1122
+ if (targetDir) {
1123
+ const staleAgentsDir = path.join(targetDir, ".github", "agents");
1124
+ if (!DRY_RUN && fs.existsSync(staleAgentsDir)) fs.rmSync(staleAgentsDir, { recursive: true, force: true });
1125
+ }
1126
+
916
1127
  const instructionsPath = targetDir
917
1128
  ? path.join(targetDir, ".github", "copilot-instructions.md")
918
1129
  : path.join(home, ".github", "copilot-instructions.md");
@@ -928,7 +1139,7 @@ function syncCopilot(entries, targetDir = null) {
928
1139
  const list = listEntries.map((e) => `- \`${adapterName(e)}\`: use \`${promptPathPrefix}/${adapterName(e)}.prompt.md\`.`).join("\n");
929
1140
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
930
1141
 
931
- Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
1142
+ For a real multi-specialist run, Copilot agent mode calls the \`orchestration_run\` MCP tool (start the engine with \`construct dashboard\`). The prompts below are reusable role profiles for single-pass work:
932
1143
 
933
1144
  ${list || "(no front-door prompts to surface)"}`;
934
1145
 
@@ -939,48 +1150,75 @@ ${list || "(no front-door prompts to surface)"}`;
939
1150
 
940
1151
  // --- VS Code adapter ---
941
1152
 
942
- function getVSCodeSettingsPaths() {
1153
+ function getVSCodeUserDirs() {
943
1154
  const platform = os.platform();
944
- const candidates = [];
1155
+ const dirs = [];
945
1156
  if (platform === "darwin") {
946
- candidates.push(
947
- path.join(home, "Library", "Application Support", "Code", "User", "settings.json"),
948
- path.join(home, "Library", "Application Support", "Code - Insiders", "User", "settings.json"),
1157
+ dirs.push(
1158
+ path.join(home, "Library", "Application Support", "Code", "User"),
1159
+ path.join(home, "Library", "Application Support", "Code - Insiders", "User"),
949
1160
  );
950
1161
  } else if (platform === "linux") {
951
- candidates.push(
952
- path.join(home, ".config", "Code", "User", "settings.json"),
953
- path.join(home, ".config", "Code - Insiders", "User", "settings.json"),
1162
+ dirs.push(
1163
+ path.join(home, ".config", "Code", "User"),
1164
+ path.join(home, ".config", "Code - Insiders", "User"),
954
1165
  );
955
1166
  } else if (platform === "win32") {
956
1167
  const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
957
- candidates.push(
958
- path.join(appData, "Code", "User", "settings.json"),
959
- path.join(appData, "Code - Insiders", "User", "settings.json"),
1168
+ dirs.push(
1169
+ path.join(appData, "Code", "User"),
1170
+ path.join(appData, "Code - Insiders", "User"),
960
1171
  );
961
1172
  }
962
- // settings.json present → ready to merge. settings.json absent but the
963
- // VS Code User dir exists → installed without ever opening Preferences,
964
- // and global sync still needs to write the MCP block. The User dir is
965
- // the installation signal; bootstrap a minimal settings.json there.
1173
+ return dirs;
1174
+ }
966
1175
 
967
- return candidates.filter((candidate) => {
968
- if (fs.existsSync(candidate)) return true;
969
- const userDir = path.dirname(candidate);
970
- return fs.existsSync(userDir);
971
- });
1176
+ function getVSCodeUserMcpPaths() {
1177
+ // VS Code's "MCP: Open User Configuration" edits `<User>/mcp.json` (top-level
1178
+ // `servers`). Global sync returns only files that already exist — per-window
1179
+ // MCP config is never seeded, mirroring the non-polluting Cursor/OpenCode
1180
+ // global behavior.
1181
+
1182
+ return getVSCodeUserDirs()
1183
+ .map((dir) => path.join(dir, "mcp.json"))
1184
+ .filter((file) => fs.existsSync(file));
972
1185
  }
973
1186
 
974
- function syncVSCode(targetDir = null) {
1187
+ function syncVSCode(targetDir = null, wants = true) {
975
1188
  const registryMcp = registry.mcpServers ?? {};
976
1189
  if (Object.keys(registryMcp).length === 0) return false;
977
1190
 
978
1191
  // Project scope writes a dedicated `.vscode/mcp.json` (VS Code's documented
979
- // workspace MCP config). Global scope mutates the user's `settings.json`
980
- // under `github.copilot.mcpServers`, preserving any non-Construct entries.
1192
+ // workspace MCP config, top-level `servers`). Global scope merges into the
1193
+ // user-profile `mcp.json` (the file "MCP: Open User Configuration" edits), and
1194
+ // only when it already exists — global sync never seeds per-window MCP config.
981
1195
 
982
1196
  if (targetDir) {
983
1197
  const mcpPath = path.join(targetDir, ".vscode", "mcp.json");
1198
+
1199
+ if (!wants) {
1200
+ if (!DRY_RUN && fs.existsSync(mcpPath)) {
1201
+ // Only delete if it matches our managed block pattern or is a simple
1202
+ // Construct-only file. For now, simple removal of the file if it
1203
+ // matches our expected content.
1204
+ try {
1205
+ const config = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
1206
+ const keys = Object.keys(config?.servers ?? {});
1207
+ const allManaged = keys.every(id => id in registryMcp);
1208
+ if (allManaged) {
1209
+ fs.rmSync(mcpPath, { force: true });
1210
+ try {
1211
+ const vscodeDir = path.dirname(mcpPath);
1212
+ if (fs.existsSync(vscodeDir) && fs.readdirSync(vscodeDir).length === 0) {
1213
+ fs.rmdirSync(vscodeDir);
1214
+ }
1215
+ } catch { /* ignore non-empty */ }
1216
+ }
1217
+ } catch { /* skip cleanup of unreadable file */ }
1218
+ }
1219
+ return false;
1220
+ }
1221
+
984
1222
  let config = { servers: {} };
985
1223
  if (fs.existsSync(mcpPath)) {
986
1224
  try { config = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || { servers: {} }; }
@@ -1004,50 +1242,40 @@ function syncVSCode(targetDir = null) {
1004
1242
  return true;
1005
1243
  }
1006
1244
 
1007
- const settingsPaths = getVSCodeSettingsPaths();
1008
- if (settingsPaths.length === 0) return false;
1245
+ const mcpPaths = getVSCodeUserMcpPaths();
1246
+ if (mcpPaths.length === 0) return false;
1009
1247
  let synced = false;
1010
- for (const settingsPath of settingsPaths) {
1248
+ for (const mcpPath of mcpPaths) {
1011
1249
  try {
1012
- let settings;
1013
- if (fs.existsSync(settingsPath)) {
1014
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
1015
- } else {
1016
- // VS Code installed (User dir present) but never opened → seed an
1017
- // empty settings.json. Single object literal, no other keys, so
1018
- // a later launch of VS Code finds it valid and writes user prefs
1019
- // alongside.
1020
- settings = {};
1021
- }
1022
- if (!settings["github.copilot.mcpServers"]) settings["github.copilot.mcpServers"] = {};
1023
- const mcpServers = settings["github.copilot.mcpServers"];
1250
+ const config = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || {};
1251
+ if (!config.servers) config.servers = {};
1024
1252
  for (const [id, mcpDef] of Object.entries(registryMcp)) {
1025
- const existingEntry = mcpServers[id];
1253
+ const existingEntry = config.servers[id];
1026
1254
  const existing = JSON.stringify(existingEntry ?? "");
1027
1255
  const hasPlaceholder = existing.includes("__");
1028
1256
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1029
1257
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1030
1258
  const transportMismatch = registryWantsCommand && existingIsRemote;
1031
1259
  if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1032
- mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1260
+ config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1033
1261
  }
1034
- settings["github.copilot.mcpServers"] = mcpServers;
1035
- if (!DRY_RUN) fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1262
+ if (!DRY_RUN) fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
1036
1263
  synced = true;
1037
- } catch { /* unreadable settings file */ }
1264
+ } catch { /* unreadable mcp.json */ }
1038
1265
  }
1039
1266
  return synced;
1040
1267
  }
1041
1268
 
1042
1269
  // --- Cursor adapter ---
1043
1270
 
1044
- function syncCursor(targetDir = null) {
1271
+ function syncCursor(targetDir = null, wants = true) {
1045
1272
  const registryMcp = registry.mcpServers ?? {};
1046
1273
  if (Object.keys(registryMcp).length === 0) return false;
1047
1274
 
1048
- const cursorMcpPath = targetDir
1049
- ? path.join(targetDir, ".cursor", "mcp.json")
1050
- : path.join(home, ".cursor", "mcp.json");
1275
+ const cursorDir = targetDir
1276
+ ? path.join(targetDir, ".cursor")
1277
+ : path.join(home, ".cursor");
1278
+ const cursorMcpPath = path.join(cursorDir, "mcp.json");
1051
1279
 
1052
1280
  // Global scope only updates Cursor's MCP config when the user has already
1053
1281
  // initialized `~/.cursor/mcp.json`; we don't conjure user-scope config out
@@ -1056,6 +1284,35 @@ function syncCursor(targetDir = null) {
1056
1284
 
1057
1285
  if (!targetDir && !fs.existsSync(cursorMcpPath)) return false;
1058
1286
 
1287
+ if (!wants) {
1288
+ if (!DRY_RUN && fs.existsSync(cursorMcpPath)) {
1289
+ try {
1290
+ const config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8"));
1291
+ const keys = Object.keys(config?.mcpServers ?? {});
1292
+ const allManaged = keys.every(id => id in registryMcp);
1293
+ if (allManaged) {
1294
+ fs.rmSync(cursorMcpPath, { force: true });
1295
+ // Also clean up rules if this was a project sync
1296
+ if (targetDir) {
1297
+ const rulesPath = path.join(cursorDir, "rules", "construct.mdc");
1298
+ if (fs.existsSync(rulesPath)) fs.rmSync(rulesPath, { force: true });
1299
+ // Remove .cursor if empty
1300
+ try {
1301
+ const rulesDir = path.join(cursorDir, "rules");
1302
+ if (fs.existsSync(rulesDir) && fs.readdirSync(rulesDir).length === 0) {
1303
+ fs.rmdirSync(rulesDir);
1304
+ }
1305
+ if (fs.existsSync(cursorDir) && fs.readdirSync(cursorDir).length === 0) {
1306
+ fs.rmdirSync(cursorDir);
1307
+ }
1308
+ } catch { /* ignore non-empty or access errors */ }
1309
+ }
1310
+ }
1311
+ } catch { /* skip cleanup of unreadable file */ }
1312
+ }
1313
+ return false;
1314
+ }
1315
+
1059
1316
  let config = { mcpServers: {} };
1060
1317
  if (fs.existsSync(cursorMcpPath)) {
1061
1318
  try { config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8")) || { mcpServers: {} }; }
@@ -1097,12 +1354,31 @@ function syncCursor(targetDir = null) {
1097
1354
  // --- OpenCode adapter ---
1098
1355
 
1099
1356
  function opencodePermissions(entry) {
1100
- if (entry.permissions) {
1101
- return Object.fromEntries(
1102
- Object.entries(entry.permissions).map(([k, v]) => [k, v])
1103
- );
1357
+ // Review-only specialists (canEdit:false) must not modify files — honor the
1358
+ // registry's canEdit contract here as Claude does, rather than defaulting every
1359
+ // specialist to edit:allow.
1360
+
1361
+ const perms = entry.permissions
1362
+ ? Object.fromEntries(Object.entries(entry.permissions).map(([k, v]) => [k, v]))
1363
+ : { edit: entry.canEdit === false ? "deny" : "allow", bash: "allow" };
1364
+
1365
+ // The orchestrator is the primary write surface; translate its abstract
1366
+ // bash:allow into OpenCode's scoped permission map so destructive commands are
1367
+ // denied and remote/history rewrites prompt — the commit-approval contract
1368
+ // enforced at the tool layer, not just in the prompt. Subagents keep the
1369
+ // abstract grant. This scoped shape is an OpenCode translation detail and stays
1370
+ // out of the cross-adapter registry.
1371
+
1372
+ if (entry.isOrchestrator && perms.bash === "allow") {
1373
+ perms.bash = {
1374
+ "*": "allow",
1375
+ "rm -rf *": "deny",
1376
+ "git push *": "ask",
1377
+ "git push --force*": "ask",
1378
+ "git reset --hard *": "ask",
1379
+ };
1104
1380
  }
1105
- return { edit: "allow", bash: "allow" };
1381
+ return perms;
1106
1382
  }
1107
1383
 
1108
1384
  function opencodeTaskPermissions(entry) {
@@ -1112,16 +1388,52 @@ function opencodeTaskPermissions(entry) {
1112
1388
  };
1113
1389
  }
1114
1390
 
1115
- function syncOpencode(entries, targetDir = null) {
1116
- // Project scope writes `.opencode/config.json` (OpenCode's documented
1117
- // per-project config layer, which shadows `~/.config/opencode/`). Global
1118
- // scope writes the user-level config and only when it already exists.
1391
+ function syncOpencode(entries, targetDir = null, wants = true) {
1392
+ // OpenCode's resolver reads `<project>/opencode.json`, `opencode.jsonc`, or
1393
+ // `<project>/.opencode/opencode.json` never `.opencode/config.json`. Write the
1394
+ // namespaced `.opencode/opencode.json` so project agents + MCP actually load; a
1395
+ // prior `.opencode/config.json` was silently ignored by the host. Global scope
1396
+ // writes the user-level config and only when it already exists.
1119
1397
 
1120
1398
  const configPath = targetDir
1121
- ? path.join(targetDir, ".opencode", "config.json")
1399
+ ? path.join(targetDir, ".opencode", "opencode.json")
1122
1400
  : findOpenCodeConfigPath();
1401
+
1123
1402
  if (!targetDir && !fs.existsSync(configPath)) return false;
1124
- if (targetDir) mkdirp(path.dirname(configPath));
1403
+
1404
+ if (!wants) {
1405
+ if (!DRY_RUN && fs.existsSync(configPath)) {
1406
+ try {
1407
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
1408
+ const agentKeys = Object.keys(config?.agent ?? {});
1409
+ const allManaged = agentKeys.every(id => id === 'construct' || (registry.specialists || []).some(s => s.name === id.replace(/^cx-/, '')));
1410
+ if (allManaged) {
1411
+ fs.rmSync(configPath, { force: true });
1412
+ if (targetDir) {
1413
+ const pluginsDir = path.join(targetDir, ".opencode", "plugins");
1414
+ if (fs.existsSync(pluginsDir)) fs.rmSync(pluginsDir, { recursive: true, force: true });
1415
+ try {
1416
+ if (fs.readdirSync(path.join(targetDir, ".opencode")).length === 0) {
1417
+ fs.rmdirSync(path.join(targetDir, ".opencode"));
1418
+ }
1419
+ } catch { /* ignore non-empty */ }
1420
+ }
1421
+ }
1422
+ } catch { /* skip cleanup */ }
1423
+ }
1424
+ return false;
1425
+ }
1426
+
1427
+ if (targetDir) {
1428
+ mkdirp(path.dirname(configPath));
1429
+ // Converge a stale `.opencode/config.json` (a path OpenCode never read) onto
1430
+ // the canonical name — rename to preserve content, or drop it if both exist.
1431
+ const legacyPath = path.join(targetDir, ".opencode", "config.json");
1432
+ if (!DRY_RUN && fs.existsSync(legacyPath)) {
1433
+ if (!fs.existsSync(configPath)) fs.renameSync(legacyPath, configPath);
1434
+ else fs.rmSync(legacyPath);
1435
+ }
1436
+ }
1125
1437
 
1126
1438
  const pluginsDir = targetDir
1127
1439
  ? path.join(targetDir, ".opencode", "plugins")
@@ -1129,7 +1441,7 @@ function syncOpencode(entries, targetDir = null) {
1129
1441
  const managedPluginPath = path.join(pluginsDir, "construct-fallback.js");
1130
1442
  const toolkitPluginPath = path.join(root, "platforms", "opencode", "plugins", "construct-fallback.js");
1131
1443
 
1132
- const writeEntries = targetDir ? entries : globalEntries(entries);
1444
+ const writeEntries = globalEntries(entries);
1133
1445
 
1134
1446
  const { config } = targetDir
1135
1447
  ? { config: fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {} }
@@ -1247,6 +1559,14 @@ function syncOpencode(entries, targetDir = null) {
1247
1559
  };
1248
1560
  }
1249
1561
 
1562
+ // Seed a cheap auxiliary model for titles and summaries when the user has not
1563
+ // chosen one — a cost lever only. The primary `model` stays the user's choice
1564
+ // and is never written. Global scope only; project configs inherit it.
1565
+
1566
+ if (!targetDir && config.small_model === undefined) {
1567
+ config.small_model = "anthropic/claude-haiku-4-5-20251001";
1568
+ }
1569
+
1250
1570
  writeOpenCodeConfig(config, configPath);
1251
1571
 
1252
1572
  const sourcePluginsDir = path.join(root, "platforms", "opencode", "plugins");
@@ -1427,38 +1747,38 @@ if (COMPRESS_PERSONAS) {
1427
1747
  acquireLock();
1428
1748
  try {
1429
1749
  if (projectDir) {
1430
- syncClaude(entries, projectDir);
1431
- syncCodex(entries, projectDir);
1432
- syncCopilot(entries, projectDir);
1433
- const opencodeOk = syncOpencode(entries, projectDir);
1434
- const vscodeOk = syncVSCode(projectDir);
1435
- const cursorOk = syncCursor(projectDir);
1436
- const cmdCount = syncCommands(projectDir);
1437
- const skillCount = syncSkills(projectDir);
1750
+ syncClaude(entries, projectDir, wantsHost("claude"));
1751
+ syncCodex(entries, projectDir, wantsHost("codex"));
1752
+ syncCopilot(entries, projectDir, wantsHost("copilot"));
1753
+ const opencodeOk = syncOpencode(entries, projectDir, wantsHost("opencode"));
1754
+ const vscodeOk = syncVSCode(projectDir, wantsHost("vscode"));
1755
+ const cursorOk = syncCursor(projectDir, wantsHost("cursor"));
1756
+ const cmdCount = wantsHost("claude") ? syncCommands(projectDir) : 0;
1757
+ const skillCount = wantsHost("claude") ? syncSkills(projectDir) : 0;
1438
1758
 
1439
1759
  if (DRY_RUN) {
1440
1760
  printDryRunDiff();
1441
1761
  } else {
1442
1762
  commitStaging();
1443
1763
  const targets = [
1444
- "Claude Code",
1445
- "Codex",
1446
- "Copilot",
1764
+ wantsHost("claude") && "Claude Code",
1765
+ wantsHost("codex") && "Codex",
1766
+ wantsHost("copilot") && "Copilot",
1447
1767
  opencodeOk && "OpenCode",
1448
1768
  vscodeOk && "VS Code",
1449
1769
  cursorOk && "Cursor",
1450
1770
  ].filter(Boolean).join(", ");
1451
- console.log(`Synced ${entries.length} agents + ${cmdCount} commands + ${skillCount} skills to ${path.relative(process.cwd(), projectDir) || "."} (project mode → ${targets}).`);
1771
+ summary(`Synced ${entries.length} agents + ${cmdCount} commands + ${skillCount} skills to ${path.relative(process.cwd(), projectDir) || "."} (project mode → ${targets}).`);
1452
1772
  }
1453
1773
  } else {
1454
1774
  const personaCount = entries.filter((e) => e.isOrchestrator).length;
1455
1775
 
1456
- syncCodex(entries);
1457
- syncClaude(entries);
1458
- syncCopilot(entries);
1459
- const opencodeOk = syncOpencode(entries);
1460
- const vscodeOk = syncVSCode();
1461
- const cursorOk = syncCursor();
1776
+ syncCodex(entries, null, wantsHost("codex"));
1777
+ syncClaude(entries, null, wantsHost("claude"));
1778
+ syncCopilot(entries, null, wantsHost("copilot"));
1779
+ const opencodeOk = syncOpencode(entries, null, wantsHost("opencode"));
1780
+ const vscodeOk = syncVSCode(null, wantsHost("vscode"));
1781
+ const cursorOk = syncCursor(null, wantsHost("cursor"));
1462
1782
  syncCommands();
1463
1783
  syncSkills();
1464
1784
 
@@ -1474,11 +1794,11 @@ try {
1474
1794
  vscodeOk && "VS Code",
1475
1795
  cursorOk && "Cursor",
1476
1796
  ].filter(Boolean).join(", ");
1477
- 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.`);
1797
+ summary(`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.`);
1478
1798
 
1479
1799
  const completionsDir = generateCompletions();
1480
1800
  if (completionsDir) {
1481
- console.log(`Completions updated → ${completionsDir}`);
1801
+ summary(`Completions updated → ${completionsDir}`);
1482
1802
  }
1483
1803
  }
1484
1804
  }