@geraldmaron/construct 1.0.20 → 1.0.23

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 (124) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +194 -16
  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 +44 -16
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +22 -0
  11. package/lib/decisions/enforced-baseline.json +3 -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/document-ingest.mjs +13 -4
  16. package/lib/embed/daemon.mjs +1 -1
  17. package/lib/embed/inbox.mjs +25 -7
  18. package/lib/embedded-contract/triage.mjs +24 -2
  19. package/lib/embedded-contract/workflow-invoke.mjs +21 -0
  20. package/lib/features.mjs +11 -11
  21. package/lib/git-hooks-path.mjs +61 -0
  22. package/lib/home-namespace.mjs +60 -0
  23. package/lib/hooks/ci-status-check.mjs +62 -40
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/stop-notify.mjs +32 -17
  27. package/lib/hooks/stop-typecheck.mjs +7 -2
  28. package/lib/host-capabilities.mjs +24 -8
  29. package/lib/host-disposition.mjs +76 -0
  30. package/lib/ingest/provider-extract.mjs +1 -1
  31. package/lib/ingest/strategy.mjs +35 -3
  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/intent-classifier.mjs +1 -1
  38. package/lib/knowledge/search.mjs +52 -3
  39. package/lib/mcp/server.mjs +57 -14
  40. package/lib/mcp/tools/memory.mjs +2 -2
  41. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  42. package/lib/model-registry.mjs +40 -33
  43. package/lib/observation-store.mjs +6 -2
  44. package/lib/opencode-config.mjs +1 -1
  45. package/lib/orchestration/events.mjs +66 -0
  46. package/lib/orchestration/run-store-postgres.mjs +85 -0
  47. package/lib/orchestration/run-store-sqlite.mjs +122 -0
  48. package/lib/orchestration/runtime.mjs +188 -50
  49. package/lib/orchestration/store.mjs +102 -0
  50. package/lib/orchestration/worker.mjs +215 -0
  51. package/lib/orchestration-policy.mjs +27 -3
  52. package/lib/parity.mjs +80 -26
  53. package/lib/policy/unified-gates.mjs +96 -0
  54. package/lib/project-init-shared.mjs +0 -173
  55. package/lib/reconcile/adapter-prune.mjs +105 -0
  56. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  57. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  58. package/lib/reconcile/index.mjs +171 -0
  59. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  60. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  61. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  62. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  63. package/lib/reconcile/postgres-namespace.mjs +102 -0
  64. package/lib/runtime/uv-bootstrap.mjs +27 -3
  65. package/lib/schema-infer.mjs +16 -2
  66. package/lib/server/csrf.mjs +14 -2
  67. package/lib/server/index.mjs +95 -0
  68. package/lib/service-manager.mjs +39 -14
  69. package/lib/setup-prompts.mjs +2 -1
  70. package/lib/setup.mjs +165 -141
  71. package/lib/storage/backend.mjs +14 -0
  72. package/lib/storage/unified-storage.mjs +550 -0
  73. package/lib/template-registry.mjs +73 -0
  74. package/lib/term-format.mjs +75 -0
  75. package/lib/uninstall/uninstall.mjs +180 -7
  76. package/package.json +2 -2
  77. package/personas/construct.md +7 -8
  78. package/platforms/claude/settings.template.json +30 -4
  79. package/platforms/opencode/config.template.json +2 -2
  80. package/rules/common/neurodivergent-output.md +66 -0
  81. package/rules/common/tool-invisibility.md +37 -0
  82. package/scripts/sync-specialists.mjs +381 -95
  83. package/skills/operating/orchestration-reference.md +2 -16
  84. package/specialists/policy-inventory.json +14 -0
  85. package/specialists/prompts/cx-accessibility.md +2 -6
  86. package/specialists/prompts/cx-ai-engineer.md +0 -4
  87. package/specialists/prompts/cx-architect.md +3 -5
  88. package/specialists/prompts/cx-business-strategist.md +0 -5
  89. package/specialists/prompts/cx-data-analyst.md +0 -4
  90. package/specialists/prompts/cx-data-engineer.md +0 -4
  91. package/specialists/prompts/cx-debugger.md +2 -6
  92. package/specialists/prompts/cx-designer.md +0 -8
  93. package/specialists/prompts/cx-devil-advocate.md +2 -2
  94. package/specialists/prompts/cx-docs-keeper.md +0 -13
  95. package/specialists/prompts/cx-engineer.md +0 -13
  96. package/specialists/prompts/cx-evaluator.md +2 -2
  97. package/specialists/prompts/cx-explorer.md +4 -5
  98. package/specialists/prompts/cx-legal-compliance.md +4 -5
  99. package/specialists/prompts/cx-operations.md +0 -5
  100. package/specialists/prompts/cx-orchestrator.md +0 -4
  101. package/specialists/prompts/cx-platform-engineer.md +0 -8
  102. package/specialists/prompts/cx-product-manager.md +0 -8
  103. package/specialists/prompts/cx-qa.md +2 -11
  104. package/specialists/prompts/cx-rd-lead.md +0 -5
  105. package/specialists/prompts/cx-release-manager.md +0 -8
  106. package/specialists/prompts/cx-researcher.md +5 -29
  107. package/specialists/prompts/cx-reviewer.md +2 -6
  108. package/specialists/prompts/cx-security.md +2 -11
  109. package/specialists/prompts/cx-sre.md +0 -15
  110. package/specialists/prompts/cx-test-automation.md +0 -4
  111. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  112. package/specialists/prompts/cx-ux-researcher.md +0 -4
  113. package/specialists/registry.json +29 -29
  114. package/templates/distribution/run.mjs +36 -7
  115. package/templates/docs/accessibility-audit.md +56 -0
  116. package/templates/docs/architecture-review.md +59 -0
  117. package/templates/docs/code-review-report.md +46 -0
  118. package/templates/docs/construct_guide.md +14 -14
  119. package/templates/docs/debug-investigation.md +53 -0
  120. package/templates/docs/qa-report.md +48 -0
  121. package/templates/docs/security-audit-report.md +48 -0
  122. package/templates/docs/task-packet.md +49 -0
  123. package/templates/docs/verdict.md +40 -0
  124. package/lib/server/static/index.html +0 -1
@@ -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) {
@@ -709,22 +831,40 @@ function writeProjectClaudeSettings(targetDir) {
709
831
  }
710
832
  }
711
833
 
834
+ // The registry is the single source of truth for MCP servers; VS Code, Codex,
835
+ // and OpenCode all wire it. Project Claude must match — above all `construct-mcp`,
836
+ // the server exposing project_context/get_skill/get_template/orchestration_policy
837
+ // that the specialist loop depends on. The curated template omits it, which left
838
+ // Claude Code as the only selected tool without the construct config. Merge the
839
+ // registry on top of the template seed; existing entries win so user edits stick,
840
+ // and buildClaudeMcpEntry resolves each server's path/env.
841
+ const registryMcp = registry.mcpServers ?? {};
842
+ existing.mcpServers ??= {};
843
+ for (const [id, mcpDef] of Object.entries(registryMcp)) {
844
+ if (existing.mcpServers[id]) continue;
845
+ existing.mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
846
+ }
847
+
712
848
  if (DRY_RUN) return;
713
849
  mkdirp(path.dirname(settingsPath));
714
850
  writeFile(settingsPath, JSON.stringify(existing, null, 2) + "\n");
715
851
  }
716
852
 
717
- function syncClaude(entries, targetDir = null) {
853
+ function syncClaude(entries, targetDir = null, wants = true) {
718
854
  const claudeAgentsDir = targetDir
719
855
  ? path.join(targetDir, ".claude", "agents")
720
856
  : path.join(home, ".claude", "agents");
721
- if (!DRY_RUN) mkdirp(claudeAgentsDir);
857
+ if (!DRY_RUN && wants) mkdirp(claudeAgentsDir);
722
858
 
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.
859
+ // Claude Code and VS Code both read the user-scope `~/.claude/agents/`, so a
860
+ // global front-door agent duplicates the project orchestrator in any editor
861
+ // that reads both scopes (the construct ×2 in the VS Code picker). Global
862
+ // scope therefore writes NO agent file — the project's own
863
+ // `.claude/agents/construct.md` is the front door; global hooks (settings.json)
864
+ // and CLAUDE.md still install. An empty write set sweeps any global agent.
865
+ // Both global and project scope now emit only the front door (Single Front Door).
726
866
 
727
- const writeEntries = targetDir ? entries : globalEntries(entries);
867
+ const writeEntries = (targetDir && wants) ? globalEntries(entries) : [];
728
868
 
729
869
  for (const entry of writeEntries) {
730
870
  const name = adapterName(entry);
@@ -733,7 +873,7 @@ function syncClaude(entries, targetDir = null) {
733
873
  }
734
874
  removeStaleAdapters(claudeAgentsDir, ".md", writeEntries);
735
875
  if (!targetDir) {
736
- sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", writeEntries.map((e) => `${adapterName(e)}.md`));
876
+ sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", []);
737
877
  }
738
878
 
739
879
  if (targetDir) {
@@ -824,14 +964,29 @@ function isCodexMcpSupported() {
824
964
  return true;
825
965
  }
826
966
 
827
- function syncCodex(entries, targetDir = null) {
967
+ // Codex aborts at startup when an MCP server's bearer_token_env_var names an env
968
+ // var that is unset ("Environment variable GITHUB_TOKEN for MCP server github is
969
+ // not set"). Unlike OpenCode — which keeps the `{env:VAR}` ref and resolves it at
970
+ // runtime — Codex must have the credential at sync time, so an entry whose token
971
+ // env var is unresolved is omitted from the Codex config (construct-n6h7).
972
+ // Entries with no credential requirement always pass.
973
+
974
+ function codexMcpEnvResolves(id, def, env = process.env) {
975
+ const entry = buildCodexMcpEntry(id, def, env);
976
+ const tokenVar = entry?.bearer_token_env_var;
977
+ if (!tokenVar) return true;
978
+ const val = env[tokenVar];
979
+ return val !== undefined && val !== "";
980
+ }
981
+
982
+ function syncCodex(entries, targetDir = null, wants = true) {
828
983
  const codexDir = targetDir
829
984
  ? path.join(targetDir, ".codex")
830
985
  : path.join(home, ".codex");
831
986
  const codexAgentsDir = path.join(codexDir, "agents");
832
- if (!DRY_RUN) mkdirp(codexAgentsDir);
987
+ if (!DRY_RUN && wants) mkdirp(codexAgentsDir);
833
988
 
834
- const writeEntries = targetDir ? entries : globalEntries(entries);
989
+ const writeEntries = wants ? globalEntries(entries) : [];
835
990
 
836
991
  for (const entry of writeEntries) {
837
992
  writeFile(path.join(codexAgentsDir, `${adapterName(entry)}.toml`), codexAgentToml(entry, entries));
@@ -847,8 +1002,12 @@ function syncCodex(entries, targetDir = null) {
847
1002
  const existing = removeDanglingConstructMcpMarkers(removeDanglingConstructMcpTimeouts(readCodexConfig(configPath)));
848
1003
  const entryNames = writeEntries.map(adapterName);
849
1004
  const registryMcp = registry.mcpServers ?? {};
1005
+ // Seed every supported MCP server (parity with Claude/OpenCode/VS Code/Cursor),
1006
+ // not just tables already present — otherwise Codex never receives `construct-mcp`
1007
+ // and the orchestration tool is unreachable there. existingMcpIds still drives
1008
+ // cleanup of any pre-existing standalone tables so the managed block stays canonical.
1009
+ const mcpIds = Object.keys(registryMcp).filter((id) => isCodexMcpSupported() && codexMcpEnvResolves(id, registryMcp[id], process.env));
850
1010
  const existingMcpIds = Object.keys(registryMcp).filter((id) => hasCodexMcpTable(existing, id));
851
- const mcpIds = existingMcpIds.filter(isCodexMcpSupported);
852
1011
  const withoutManagedTables = removeDanglingConstructMcpMarkers(removeTomlTables(
853
1012
  removeCodexAgentTables(existing, entryNames),
854
1013
  existingMcpIds.flatMap((id) => [`mcp_servers.${id}`, `mcp_servers.${tomlString(id)}`]),
@@ -856,6 +1015,14 @@ function syncCodex(entries, targetDir = null) {
856
1015
  const hasAgentsRoot = /^\[agents\]\s*$/m.test(withoutManagedTables);
857
1016
  const rootBlock = hasAgentsRoot ? "" : "[agents]\nmax_threads = 6\nmax_depth = 1\n\n";
858
1017
 
1018
+ // Workspace-write agents (the orchestrator + canEdit specialists) need network
1019
+ // access for WebFetch/context7 — Codex's workspace-write sandbox blocks the
1020
+ // network unless this is set. Skip when the user already manages the table so
1021
+ // their own sandbox settings (e.g. writable_roots) are not clobbered.
1022
+
1023
+ const hasSandboxConfig = /^\[sandbox_workspace_write\]/m.test(withoutManagedTables);
1024
+ const sandboxBlock = hasSandboxConfig ? "" : "[sandbox_workspace_write]\nnetwork_access = true\n\n";
1025
+
859
1026
  // In project scope every entry is reachable from the user (Construct dispatches
860
1027
  // internal specialists itself, but project teammates may want to address them).
861
1028
  // In global scope only the `construct` front-door agent is registered.
@@ -869,7 +1036,7 @@ config_file = ${tomlString(`agents/${adapterName(e)}.toml`)}
869
1036
  const mcpBlock = mcpIds
870
1037
  .map((id) => serializeCodexMcpTable(id, buildCodexMcpEntry(id, registryMcp[id], process.env)))
871
1038
  .join("\n\n");
872
- const withAgents = replaceManagedBlock(withoutManagedTables, `${rootBlock}${blocks}`);
1039
+ const withAgents = replaceManagedBlock(withoutManagedTables, `${sandboxBlock}${rootBlock}${blocks}`);
873
1040
  writeCodexConfig(replaceManagedBlock(
874
1041
  withAgents,
875
1042
  mcpBlock,
@@ -897,13 +1064,13 @@ When using this prompt, stay within the role above and adapt to the current repo
897
1064
  `;
898
1065
  }
899
1066
 
900
- function syncCopilot(entries, targetDir = null) {
1067
+ function syncCopilot(entries, targetDir = null, wants = true) {
901
1068
  const promptsDir = targetDir
902
1069
  ? path.join(targetDir, ".github", "prompts")
903
1070
  : path.join(home, ".github", "prompts");
904
- if (!DRY_RUN) mkdirp(promptsDir);
1071
+ if (!DRY_RUN && wants) mkdirp(promptsDir);
905
1072
 
906
- const writeEntries = targetDir ? entries : globalEntries(entries);
1073
+ const writeEntries = wants ? globalEntries(entries) : [];
907
1074
 
908
1075
  for (const entry of writeEntries) {
909
1076
  writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
@@ -913,6 +1080,16 @@ function syncCopilot(entries, targetDir = null) {
913
1080
  sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
914
1081
  }
915
1082
 
1083
+ // VS Code / Copilot agent mode reads the project's `.claude/agents/*.md`
1084
+ // natively (Claude-format custom agents), so Construct does NOT write a
1085
+ // separate `.github/agents/` set — doing so duplicated every agent in the
1086
+ // picker. A pre-existing `.github/agents/` from an earlier sync is swept.
1087
+
1088
+ if (targetDir) {
1089
+ const staleAgentsDir = path.join(targetDir, ".github", "agents");
1090
+ if (!DRY_RUN && fs.existsSync(staleAgentsDir)) fs.rmSync(staleAgentsDir, { recursive: true, force: true });
1091
+ }
1092
+
916
1093
  const instructionsPath = targetDir
917
1094
  ? path.join(targetDir, ".github", "copilot-instructions.md")
918
1095
  : path.join(home, ".github", "copilot-instructions.md");
@@ -928,7 +1105,7 @@ function syncCopilot(entries, targetDir = null) {
928
1105
  const list = listEntries.map((e) => `- \`${adapterName(e)}\`: use \`${promptPathPrefix}/${adapterName(e)}.prompt.md\`.`).join("\n");
929
1106
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
930
1107
 
931
- Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
1108
+ 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
1109
 
933
1110
  ${list || "(no front-door prompts to surface)"}`;
934
1111
 
@@ -939,48 +1116,75 @@ ${list || "(no front-door prompts to surface)"}`;
939
1116
 
940
1117
  // --- VS Code adapter ---
941
1118
 
942
- function getVSCodeSettingsPaths() {
1119
+ function getVSCodeUserDirs() {
943
1120
  const platform = os.platform();
944
- const candidates = [];
1121
+ const dirs = [];
945
1122
  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"),
1123
+ dirs.push(
1124
+ path.join(home, "Library", "Application Support", "Code", "User"),
1125
+ path.join(home, "Library", "Application Support", "Code - Insiders", "User"),
949
1126
  );
950
1127
  } 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"),
1128
+ dirs.push(
1129
+ path.join(home, ".config", "Code", "User"),
1130
+ path.join(home, ".config", "Code - Insiders", "User"),
954
1131
  );
955
1132
  } else if (platform === "win32") {
956
1133
  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"),
1134
+ dirs.push(
1135
+ path.join(appData, "Code", "User"),
1136
+ path.join(appData, "Code - Insiders", "User"),
960
1137
  );
961
1138
  }
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.
1139
+ return dirs;
1140
+ }
966
1141
 
967
- return candidates.filter((candidate) => {
968
- if (fs.existsSync(candidate)) return true;
969
- const userDir = path.dirname(candidate);
970
- return fs.existsSync(userDir);
971
- });
1142
+ function getVSCodeUserMcpPaths() {
1143
+ // VS Code's "MCP: Open User Configuration" edits `<User>/mcp.json` (top-level
1144
+ // `servers`). Global sync returns only files that already exist — per-window
1145
+ // MCP config is never seeded, mirroring the non-polluting Cursor/OpenCode
1146
+ // global behavior.
1147
+
1148
+ return getVSCodeUserDirs()
1149
+ .map((dir) => path.join(dir, "mcp.json"))
1150
+ .filter((file) => fs.existsSync(file));
972
1151
  }
973
1152
 
974
- function syncVSCode(targetDir = null) {
1153
+ function syncVSCode(targetDir = null, wants = true) {
975
1154
  const registryMcp = registry.mcpServers ?? {};
976
1155
  if (Object.keys(registryMcp).length === 0) return false;
977
1156
 
978
1157
  // 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.
1158
+ // workspace MCP config, top-level `servers`). Global scope merges into the
1159
+ // user-profile `mcp.json` (the file "MCP: Open User Configuration" edits), and
1160
+ // only when it already exists — global sync never seeds per-window MCP config.
981
1161
 
982
1162
  if (targetDir) {
983
1163
  const mcpPath = path.join(targetDir, ".vscode", "mcp.json");
1164
+
1165
+ if (!wants) {
1166
+ if (!DRY_RUN && fs.existsSync(mcpPath)) {
1167
+ // Only delete if it matches our managed block pattern or is a simple
1168
+ // Construct-only file. For now, simple removal of the file if it
1169
+ // matches our expected content.
1170
+ try {
1171
+ const config = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
1172
+ const keys = Object.keys(config?.servers ?? {});
1173
+ const allManaged = keys.every(id => id in registryMcp);
1174
+ if (allManaged) {
1175
+ fs.rmSync(mcpPath, { force: true });
1176
+ try {
1177
+ const vscodeDir = path.dirname(mcpPath);
1178
+ if (fs.existsSync(vscodeDir) && fs.readdirSync(vscodeDir).length === 0) {
1179
+ fs.rmdirSync(vscodeDir);
1180
+ }
1181
+ } catch { /* ignore non-empty */ }
1182
+ }
1183
+ } catch { /* skip cleanup of unreadable file */ }
1184
+ }
1185
+ return false;
1186
+ }
1187
+
984
1188
  let config = { servers: {} };
985
1189
  if (fs.existsSync(mcpPath)) {
986
1190
  try { config = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || { servers: {} }; }
@@ -1004,50 +1208,40 @@ function syncVSCode(targetDir = null) {
1004
1208
  return true;
1005
1209
  }
1006
1210
 
1007
- const settingsPaths = getVSCodeSettingsPaths();
1008
- if (settingsPaths.length === 0) return false;
1211
+ const mcpPaths = getVSCodeUserMcpPaths();
1212
+ if (mcpPaths.length === 0) return false;
1009
1213
  let synced = false;
1010
- for (const settingsPath of settingsPaths) {
1214
+ for (const mcpPath of mcpPaths) {
1011
1215
  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"];
1216
+ const config = JSON.parse(fs.readFileSync(mcpPath, "utf8")) || {};
1217
+ if (!config.servers) config.servers = {};
1024
1218
  for (const [id, mcpDef] of Object.entries(registryMcp)) {
1025
- const existingEntry = mcpServers[id];
1219
+ const existingEntry = config.servers[id];
1026
1220
  const existing = JSON.stringify(existingEntry ?? "");
1027
1221
  const hasPlaceholder = existing.includes("__");
1028
1222
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1029
1223
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1030
1224
  const transportMismatch = registryWantsCommand && existingIsRemote;
1031
1225
  if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1032
- mcpServers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1226
+ config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
1033
1227
  }
1034
- settings["github.copilot.mcpServers"] = mcpServers;
1035
- if (!DRY_RUN) fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1228
+ if (!DRY_RUN) fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
1036
1229
  synced = true;
1037
- } catch { /* unreadable settings file */ }
1230
+ } catch { /* unreadable mcp.json */ }
1038
1231
  }
1039
1232
  return synced;
1040
1233
  }
1041
1234
 
1042
1235
  // --- Cursor adapter ---
1043
1236
 
1044
- function syncCursor(targetDir = null) {
1237
+ function syncCursor(targetDir = null, wants = true) {
1045
1238
  const registryMcp = registry.mcpServers ?? {};
1046
1239
  if (Object.keys(registryMcp).length === 0) return false;
1047
1240
 
1048
- const cursorMcpPath = targetDir
1049
- ? path.join(targetDir, ".cursor", "mcp.json")
1050
- : path.join(home, ".cursor", "mcp.json");
1241
+ const cursorDir = targetDir
1242
+ ? path.join(targetDir, ".cursor")
1243
+ : path.join(home, ".cursor");
1244
+ const cursorMcpPath = path.join(cursorDir, "mcp.json");
1051
1245
 
1052
1246
  // Global scope only updates Cursor's MCP config when the user has already
1053
1247
  // initialized `~/.cursor/mcp.json`; we don't conjure user-scope config out
@@ -1056,6 +1250,35 @@ function syncCursor(targetDir = null) {
1056
1250
 
1057
1251
  if (!targetDir && !fs.existsSync(cursorMcpPath)) return false;
1058
1252
 
1253
+ if (!wants) {
1254
+ if (!DRY_RUN && fs.existsSync(cursorMcpPath)) {
1255
+ try {
1256
+ const config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8"));
1257
+ const keys = Object.keys(config?.mcpServers ?? {});
1258
+ const allManaged = keys.every(id => id in registryMcp);
1259
+ if (allManaged) {
1260
+ fs.rmSync(cursorMcpPath, { force: true });
1261
+ // Also clean up rules if this was a project sync
1262
+ if (targetDir) {
1263
+ const rulesPath = path.join(cursorDir, "rules", "construct.mdc");
1264
+ if (fs.existsSync(rulesPath)) fs.rmSync(rulesPath, { force: true });
1265
+ // Remove .cursor if empty
1266
+ try {
1267
+ const rulesDir = path.join(cursorDir, "rules");
1268
+ if (fs.existsSync(rulesDir) && fs.readdirSync(rulesDir).length === 0) {
1269
+ fs.rmdirSync(rulesDir);
1270
+ }
1271
+ if (fs.existsSync(cursorDir) && fs.readdirSync(cursorDir).length === 0) {
1272
+ fs.rmdirSync(cursorDir);
1273
+ }
1274
+ } catch { /* ignore non-empty or access errors */ }
1275
+ }
1276
+ }
1277
+ } catch { /* skip cleanup of unreadable file */ }
1278
+ }
1279
+ return false;
1280
+ }
1281
+
1059
1282
  let config = { mcpServers: {} };
1060
1283
  if (fs.existsSync(cursorMcpPath)) {
1061
1284
  try { config = JSON.parse(fs.readFileSync(cursorMcpPath, "utf8")) || { mcpServers: {} }; }
@@ -1097,12 +1320,31 @@ function syncCursor(targetDir = null) {
1097
1320
  // --- OpenCode adapter ---
1098
1321
 
1099
1322
  function opencodePermissions(entry) {
1100
- if (entry.permissions) {
1101
- return Object.fromEntries(
1102
- Object.entries(entry.permissions).map(([k, v]) => [k, v])
1103
- );
1323
+ // Review-only specialists (canEdit:false) must not modify files — honor the
1324
+ // registry's canEdit contract here as Claude does, rather than defaulting every
1325
+ // specialist to edit:allow.
1326
+
1327
+ const perms = entry.permissions
1328
+ ? Object.fromEntries(Object.entries(entry.permissions).map(([k, v]) => [k, v]))
1329
+ : { edit: entry.canEdit === false ? "deny" : "allow", bash: "allow" };
1330
+
1331
+ // The orchestrator is the primary write surface; translate its abstract
1332
+ // bash:allow into OpenCode's scoped permission map so destructive commands are
1333
+ // denied and remote/history rewrites prompt — the commit-approval contract
1334
+ // enforced at the tool layer, not just in the prompt. Subagents keep the
1335
+ // abstract grant. This scoped shape is an OpenCode translation detail and stays
1336
+ // out of the cross-adapter registry.
1337
+
1338
+ if (entry.isOrchestrator && perms.bash === "allow") {
1339
+ perms.bash = {
1340
+ "*": "allow",
1341
+ "rm -rf *": "deny",
1342
+ "git push *": "ask",
1343
+ "git push --force*": "ask",
1344
+ "git reset --hard *": "ask",
1345
+ };
1104
1346
  }
1105
- return { edit: "allow", bash: "allow" };
1347
+ return perms;
1106
1348
  }
1107
1349
 
1108
1350
  function opencodeTaskPermissions(entry) {
@@ -1112,16 +1354,52 @@ function opencodeTaskPermissions(entry) {
1112
1354
  };
1113
1355
  }
1114
1356
 
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.
1357
+ function syncOpencode(entries, targetDir = null, wants = true) {
1358
+ // OpenCode's resolver reads `<project>/opencode.json`, `opencode.jsonc`, or
1359
+ // `<project>/.opencode/opencode.json` never `.opencode/config.json`. Write the
1360
+ // namespaced `.opencode/opencode.json` so project agents + MCP actually load; a
1361
+ // prior `.opencode/config.json` was silently ignored by the host. Global scope
1362
+ // writes the user-level config and only when it already exists.
1119
1363
 
1120
1364
  const configPath = targetDir
1121
- ? path.join(targetDir, ".opencode", "config.json")
1365
+ ? path.join(targetDir, ".opencode", "opencode.json")
1122
1366
  : findOpenCodeConfigPath();
1367
+
1123
1368
  if (!targetDir && !fs.existsSync(configPath)) return false;
1124
- if (targetDir) mkdirp(path.dirname(configPath));
1369
+
1370
+ if (!wants) {
1371
+ if (!DRY_RUN && fs.existsSync(configPath)) {
1372
+ try {
1373
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
1374
+ const agentKeys = Object.keys(config?.agent ?? {});
1375
+ const allManaged = agentKeys.every(id => id === 'construct' || (registry.specialists || []).some(s => s.name === id.replace(/^cx-/, '')));
1376
+ if (allManaged) {
1377
+ fs.rmSync(configPath, { force: true });
1378
+ if (targetDir) {
1379
+ const pluginsDir = path.join(targetDir, ".opencode", "plugins");
1380
+ if (fs.existsSync(pluginsDir)) fs.rmSync(pluginsDir, { recursive: true, force: true });
1381
+ try {
1382
+ if (fs.readdirSync(path.join(targetDir, ".opencode")).length === 0) {
1383
+ fs.rmdirSync(path.join(targetDir, ".opencode"));
1384
+ }
1385
+ } catch { /* ignore non-empty */ }
1386
+ }
1387
+ }
1388
+ } catch { /* skip cleanup */ }
1389
+ }
1390
+ return false;
1391
+ }
1392
+
1393
+ if (targetDir) {
1394
+ mkdirp(path.dirname(configPath));
1395
+ // Converge a stale `.opencode/config.json` (a path OpenCode never read) onto
1396
+ // the canonical name — rename to preserve content, or drop it if both exist.
1397
+ const legacyPath = path.join(targetDir, ".opencode", "config.json");
1398
+ if (!DRY_RUN && fs.existsSync(legacyPath)) {
1399
+ if (!fs.existsSync(configPath)) fs.renameSync(legacyPath, configPath);
1400
+ else fs.rmSync(legacyPath);
1401
+ }
1402
+ }
1125
1403
 
1126
1404
  const pluginsDir = targetDir
1127
1405
  ? path.join(targetDir, ".opencode", "plugins")
@@ -1129,7 +1407,7 @@ function syncOpencode(entries, targetDir = null) {
1129
1407
  const managedPluginPath = path.join(pluginsDir, "construct-fallback.js");
1130
1408
  const toolkitPluginPath = path.join(root, "platforms", "opencode", "plugins", "construct-fallback.js");
1131
1409
 
1132
- const writeEntries = targetDir ? entries : globalEntries(entries);
1410
+ const writeEntries = globalEntries(entries);
1133
1411
 
1134
1412
  const { config } = targetDir
1135
1413
  ? { config: fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {} }
@@ -1247,6 +1525,14 @@ function syncOpencode(entries, targetDir = null) {
1247
1525
  };
1248
1526
  }
1249
1527
 
1528
+ // Seed a cheap auxiliary model for titles and summaries when the user has not
1529
+ // chosen one — a cost lever only. The primary `model` stays the user's choice
1530
+ // and is never written. Global scope only; project configs inherit it.
1531
+
1532
+ if (!targetDir && config.small_model === undefined) {
1533
+ config.small_model = "anthropic/claude-haiku-4-5-20251001";
1534
+ }
1535
+
1250
1536
  writeOpenCodeConfig(config, configPath);
1251
1537
 
1252
1538
  const sourcePluginsDir = path.join(root, "platforms", "opencode", "plugins");
@@ -1427,38 +1713,38 @@ if (COMPRESS_PERSONAS) {
1427
1713
  acquireLock();
1428
1714
  try {
1429
1715
  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);
1716
+ syncClaude(entries, projectDir, wantsHost("claude"));
1717
+ syncCodex(entries, projectDir, wantsHost("codex"));
1718
+ syncCopilot(entries, projectDir, wantsHost("copilot"));
1719
+ const opencodeOk = syncOpencode(entries, projectDir, wantsHost("opencode"));
1720
+ const vscodeOk = syncVSCode(projectDir, wantsHost("vscode"));
1721
+ const cursorOk = syncCursor(projectDir, wantsHost("cursor"));
1722
+ const cmdCount = wantsHost("claude") ? syncCommands(projectDir) : 0;
1723
+ const skillCount = wantsHost("claude") ? syncSkills(projectDir) : 0;
1438
1724
 
1439
1725
  if (DRY_RUN) {
1440
1726
  printDryRunDiff();
1441
1727
  } else {
1442
1728
  commitStaging();
1443
1729
  const targets = [
1444
- "Claude Code",
1445
- "Codex",
1446
- "Copilot",
1730
+ wantsHost("claude") && "Claude Code",
1731
+ wantsHost("codex") && "Codex",
1732
+ wantsHost("copilot") && "Copilot",
1447
1733
  opencodeOk && "OpenCode",
1448
1734
  vscodeOk && "VS Code",
1449
1735
  cursorOk && "Cursor",
1450
1736
  ].filter(Boolean).join(", ");
1451
- console.log(`Synced ${entries.length} agents + ${cmdCount} commands + ${skillCount} skills to ${path.relative(process.cwd(), projectDir) || "."} (project mode → ${targets}).`);
1737
+ summary(`Synced ${entries.length} agents + ${cmdCount} commands + ${skillCount} skills to ${path.relative(process.cwd(), projectDir) || "."} (project mode → ${targets}).`);
1452
1738
  }
1453
1739
  } else {
1454
1740
  const personaCount = entries.filter((e) => e.isOrchestrator).length;
1455
1741
 
1456
- syncCodex(entries);
1457
- syncClaude(entries);
1458
- syncCopilot(entries);
1459
- const opencodeOk = syncOpencode(entries);
1460
- const vscodeOk = syncVSCode();
1461
- const cursorOk = syncCursor();
1742
+ syncCodex(entries, null, wantsHost("codex"));
1743
+ syncClaude(entries, null, wantsHost("claude"));
1744
+ syncCopilot(entries, null, wantsHost("copilot"));
1745
+ const opencodeOk = syncOpencode(entries, null, wantsHost("opencode"));
1746
+ const vscodeOk = syncVSCode(null, wantsHost("vscode"));
1747
+ const cursorOk = syncCursor(null, wantsHost("cursor"));
1462
1748
  syncCommands();
1463
1749
  syncSkills();
1464
1750
 
@@ -1474,11 +1760,11 @@ try {
1474
1760
  vscodeOk && "VS Code",
1475
1761
  cursorOk && "Cursor",
1476
1762
  ].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.`);
1763
+ 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
1764
 
1479
1765
  const completionsDir = generateCompletions();
1480
1766
  if (completionsDir) {
1481
- console.log(`Completions updated → ${completionsDir}`);
1767
+ summary(`Completions updated → ${completionsDir}`);
1482
1768
  }
1483
1769
  }
1484
1770
  }