@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.
- package/README.md +23 -7
- package/bin/construct +206 -228
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +31 -10
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +39 -163
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +72 -10
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +169 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +59 -252
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +104 -480
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -43
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +7 -6
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -11
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +427 -107
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +3 -12
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +28 -36
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +15 -15
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/server/static/index.html +0 -1
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- 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
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
1153
|
+
function getVSCodeUserDirs() {
|
|
943
1154
|
const platform = os.platform();
|
|
944
|
-
const
|
|
1155
|
+
const dirs = [];
|
|
945
1156
|
if (platform === "darwin") {
|
|
946
|
-
|
|
947
|
-
path.join(home, "Library", "Application Support", "Code", "User"
|
|
948
|
-
path.join(home, "Library", "Application Support", "Code - Insiders", "User"
|
|
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
|
-
|
|
952
|
-
path.join(home, ".config", "Code", "User"
|
|
953
|
-
path.join(home, ".config", "Code - Insiders", "User"
|
|
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
|
-
|
|
958
|
-
path.join(appData, "Code", "User"
|
|
959
|
-
path.join(appData, "Code - Insiders", "User"
|
|
1168
|
+
dirs.push(
|
|
1169
|
+
path.join(appData, "Code", "User"),
|
|
1170
|
+
path.join(appData, "Code - Insiders", "User"),
|
|
960
1171
|
);
|
|
961
1172
|
}
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
980
|
-
//
|
|
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
|
|
1008
|
-
if (
|
|
1245
|
+
const mcpPaths = getVSCodeUserMcpPaths();
|
|
1246
|
+
if (mcpPaths.length === 0) return false;
|
|
1009
1247
|
let synced = false;
|
|
1010
|
-
for (const
|
|
1248
|
+
for (const mcpPath of mcpPaths) {
|
|
1011
1249
|
try {
|
|
1012
|
-
|
|
1013
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
1260
|
+
config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env);
|
|
1033
1261
|
}
|
|
1034
|
-
|
|
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
|
|
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
|
|
1049
|
-
? path.join(targetDir, ".cursor"
|
|
1050
|
-
: path.join(home, ".cursor"
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
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
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
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", "
|
|
1399
|
+
? path.join(targetDir, ".opencode", "opencode.json")
|
|
1122
1400
|
: findOpenCodeConfigPath();
|
|
1401
|
+
|
|
1123
1402
|
if (!targetDir && !fs.existsSync(configPath)) return false;
|
|
1124
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1801
|
+
summary(`Completions updated → ${completionsDir}`);
|
|
1482
1802
|
}
|
|
1483
1803
|
}
|
|
1484
1804
|
}
|