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