@geraldmaron/construct 1.0.11 → 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 +55 -18
- package/bin/construct +259 -11
- package/bin/construct-postinstall.mjs +23 -2
- package/lib/auto-docs.mjs +23 -22
- package/lib/cli-commands.mjs +2 -0
- 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/sync/skill-frontmatter.mjs +71 -0
- 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 +296 -94
- 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.
|
|
@@ -41,6 +55,7 @@ import { inlineRoleAntiPatterns, PROMPT_WORD_CAP } from "../lib/role-preload.mjs
|
|
|
41
55
|
import { resolveActiveProfile } from "../lib/profiles/loader.mjs";
|
|
42
56
|
import { resolveTiersForPrimary } from "../lib/model-router.mjs";
|
|
43
57
|
import { stampFrontmatter } from "../lib/doc-stamp.mjs";
|
|
58
|
+
import { buildSkillFrontmatter, stripLeadingFrontmatter } from "../lib/sync/skill-frontmatter.mjs";
|
|
44
59
|
|
|
45
60
|
const home = os.homedir();
|
|
46
61
|
const root = path.resolve(import.meta.dirname, "..");
|
|
@@ -138,10 +153,36 @@ if (validationErrors.length > 0) {
|
|
|
138
153
|
|
|
139
154
|
const DRY_RUN = process.argv.includes("--dry-run");
|
|
140
155
|
const COMPRESS_PERSONAS = process.argv.includes("--compress-personas");
|
|
141
|
-
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;
|
|
142
175
|
const lockPath = path.join(projectDir || root, ".cx", "sync.lock");
|
|
143
176
|
const stagingDir = path.join(projectDir || root, ".cx", "sync-staging");
|
|
144
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
|
+
|
|
145
186
|
/** Acquire an exclusive lockfile. Aborts if already held by a live process. */
|
|
146
187
|
function acquireLock() {
|
|
147
188
|
if (DRY_RUN) return;
|
|
@@ -180,9 +221,18 @@ function releaseLock() {
|
|
|
180
221
|
*/
|
|
181
222
|
const _stagedPairs = []; // [{ staging, real, content }]
|
|
182
223
|
|
|
183
|
-
function writeFile(file, content) {
|
|
224
|
+
function writeFile(file, content, { stamp = true } = {}) {
|
|
184
225
|
mkdirp(path.dirname(file));
|
|
185
|
-
|
|
226
|
+
// Doc-stamp wraps content with cx_doc_id + body_hash YAML frontmatter for
|
|
227
|
+
// tamper detection. That's right for content artifacts (research findings,
|
|
228
|
+
// knowledge files), wrong for host-platform adapter files that have their
|
|
229
|
+
// own frontmatter contract (Claude Code agents, Copilot prompts, Anthropic
|
|
230
|
+
// Agent Skills) or are user-managed (CLAUDE.md, copilot-instructions.md).
|
|
231
|
+
// Stamping those produces double-frontmatter that breaks the host loader.
|
|
232
|
+
// Callers writing those files pass { stamp: false }.
|
|
233
|
+
|
|
234
|
+
const shouldStamp = stamp && file.endsWith('.md');
|
|
235
|
+
const stamped = shouldStamp ? stampFrontmatter(content, { generator: 'construct/sync-specialists' }) : content;
|
|
186
236
|
|
|
187
237
|
if (DRY_RUN) {
|
|
188
238
|
// Stage in memory only — compare against current on-disk content.
|
|
@@ -488,6 +538,7 @@ function removeStaleAdapters(dir, ext, entries) {
|
|
|
488
538
|
}
|
|
489
539
|
|
|
490
540
|
// Stale manifest entries — delete files not in the current expected set.
|
|
541
|
+
|
|
491
542
|
const previouslyWritten = readManifest(dir);
|
|
492
543
|
for (const file of previouslyWritten) {
|
|
493
544
|
if (!expected.has(file) && fs.existsSync(path.join(dir, file))) {
|
|
@@ -498,6 +549,28 @@ function removeStaleAdapters(dir, ext, entries) {
|
|
|
498
549
|
writeManifest(dir, expected);
|
|
499
550
|
}
|
|
500
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
|
+
|
|
501
574
|
// --- Unified entry list: personas + agents ---
|
|
502
575
|
|
|
503
576
|
function buildEntries() {
|
|
@@ -638,12 +711,21 @@ function syncClaude(entries, targetDir = null) {
|
|
|
638
711
|
: path.join(home, ".claude", "agents");
|
|
639
712
|
if (!DRY_RUN) mkdirp(claudeAgentsDir);
|
|
640
713
|
|
|
641
|
-
|
|
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) {
|
|
642
721
|
const name = adapterName(entry);
|
|
643
722
|
const md = claudeAgentMarkdown(entry, entries);
|
|
644
|
-
writeFile(path.join(claudeAgentsDir, `${name}.md`), md);
|
|
723
|
+
writeFile(path.join(claudeAgentsDir, `${name}.md`), md, { stamp: false });
|
|
724
|
+
}
|
|
725
|
+
removeStaleAdapters(claudeAgentsDir, ".md", writeEntries);
|
|
726
|
+
if (!targetDir) {
|
|
727
|
+
sweepLegacyPrefixedFiles(claudeAgentsDir, ".md", writeEntries.map((e) => `${adapterName(e)}.md`));
|
|
645
728
|
}
|
|
646
|
-
removeStaleAdapters(claudeAgentsDir, ".md", entries);
|
|
647
729
|
|
|
648
730
|
if (targetDir) {
|
|
649
731
|
writeProjectClaudeSettings(targetDir);
|
|
@@ -654,16 +736,15 @@ function syncClaude(entries, targetDir = null) {
|
|
|
654
736
|
const claudeMdPath = path.join(home, ".claude", "CLAUDE.md");
|
|
655
737
|
const existing = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, "utf8") : "# Claude Global Instructions\n";
|
|
656
738
|
const personaList = entries.filter((e) => e.isOrchestrator).map((e) => `- \`${adapterName(e)}\`: ${e.role} — ${e.description}`).join("\n");
|
|
657
|
-
// Only surface non-internal specialists in CLAUDE.md — internal specialists are dispatched by Construct, not invoked by users
|
|
658
|
-
const specialistList = entries.filter((e) => !e.isOrchestrator && !e.internal).map((e) => `- \`${adapterName(e)}\`: ${e.description}`).join("\n");
|
|
659
739
|
const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Personas
|
|
660
740
|
|
|
661
741
|
${personaList}
|
|
662
742
|
|
|
663
743
|
## Internal Specialists
|
|
664
744
|
|
|
665
|
-
|
|
666
|
-
|
|
745
|
+
(all specialists are internal — routed through Construct, available inside a Construct-initialized project)`;
|
|
746
|
+
// User-managed file with our managed-block carved out — never doc-stamp.
|
|
747
|
+
writeFile(claudeMdPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
|
|
667
748
|
|
|
668
749
|
// Sync MCP servers into ~/.claude/settings.json if it exists
|
|
669
750
|
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
@@ -734,19 +815,28 @@ function isCodexMcpSupported() {
|
|
|
734
815
|
return true;
|
|
735
816
|
}
|
|
736
817
|
|
|
737
|
-
function syncCodex(entries) {
|
|
738
|
-
const codexDir =
|
|
818
|
+
function syncCodex(entries, targetDir = null) {
|
|
819
|
+
const codexDir = targetDir
|
|
820
|
+
? path.join(targetDir, ".codex")
|
|
821
|
+
: path.join(home, ".codex");
|
|
739
822
|
const codexAgentsDir = path.join(codexDir, "agents");
|
|
740
823
|
if (!DRY_RUN) mkdirp(codexAgentsDir);
|
|
741
824
|
|
|
742
|
-
|
|
825
|
+
const writeEntries = targetDir ? entries : globalEntries(entries);
|
|
826
|
+
|
|
827
|
+
for (const entry of writeEntries) {
|
|
743
828
|
writeFile(path.join(codexAgentsDir, `${adapterName(entry)}.toml`), codexAgentToml(entry, entries));
|
|
744
829
|
}
|
|
745
|
-
removeStaleAdapters(codexAgentsDir, ".toml",
|
|
830
|
+
removeStaleAdapters(codexAgentsDir, ".toml", writeEntries);
|
|
831
|
+
if (!targetDir) {
|
|
832
|
+
sweepLegacyPrefixedFiles(codexAgentsDir, ".toml", writeEntries.map((e) => `${adapterName(e)}.toml`));
|
|
833
|
+
}
|
|
746
834
|
|
|
747
|
-
const configPath =
|
|
835
|
+
const configPath = targetDir
|
|
836
|
+
? path.join(codexDir, "config.toml")
|
|
837
|
+
: getCodexConfigPath(home);
|
|
748
838
|
const existing = removeDanglingConstructMcpMarkers(removeDanglingConstructMcpTimeouts(readCodexConfig(configPath)));
|
|
749
|
-
const entryNames =
|
|
839
|
+
const entryNames = writeEntries.map(adapterName);
|
|
750
840
|
const registryMcp = registry.mcpServers ?? {};
|
|
751
841
|
const existingMcpIds = Object.keys(registryMcp).filter((id) => hasCodexMcpTable(existing, id));
|
|
752
842
|
const mcpIds = existingMcpIds.filter(isCodexMcpSupported);
|
|
@@ -756,8 +846,13 @@ function syncCodex(entries) {
|
|
|
756
846
|
));
|
|
757
847
|
const hasAgentsRoot = /^\[agents\]\s*$/m.test(withoutManagedTables);
|
|
758
848
|
const rootBlock = hasAgentsRoot ? "" : "[agents]\nmax_threads = 6\nmax_depth = 1\n\n";
|
|
759
|
-
|
|
760
|
-
|
|
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)}]
|
|
761
856
|
description = ${tomlString(e.description)}
|
|
762
857
|
config_file = ${tomlString(`agents/${adapterName(e)}.toml`)}
|
|
763
858
|
`).join("\n");
|
|
@@ -793,25 +888,44 @@ When using this prompt, stay within the role above and adapt to the current repo
|
|
|
793
888
|
`;
|
|
794
889
|
}
|
|
795
890
|
|
|
796
|
-
function syncCopilot(entries) {
|
|
797
|
-
const promptsDir =
|
|
891
|
+
function syncCopilot(entries, targetDir = null) {
|
|
892
|
+
const promptsDir = targetDir
|
|
893
|
+
? path.join(targetDir, ".github", "prompts")
|
|
894
|
+
: path.join(home, ".github", "prompts");
|
|
798
895
|
if (!DRY_RUN) mkdirp(promptsDir);
|
|
799
|
-
|
|
800
|
-
|
|
896
|
+
|
|
897
|
+
const writeEntries = targetDir ? entries : globalEntries(entries);
|
|
898
|
+
|
|
899
|
+
for (const entry of writeEntries) {
|
|
900
|
+
writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
|
|
901
|
+
}
|
|
902
|
+
removeStaleAdapters(promptsDir, ".prompt.md", writeEntries);
|
|
903
|
+
if (!targetDir) {
|
|
904
|
+
sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
|
|
801
905
|
}
|
|
802
|
-
removeStaleAdapters(promptsDir, ".prompt.md", entries);
|
|
803
906
|
|
|
804
|
-
const instructionsPath =
|
|
907
|
+
const instructionsPath = targetDir
|
|
908
|
+
? path.join(targetDir, ".github", "copilot-instructions.md")
|
|
909
|
+
: path.join(home, ".github", "copilot-instructions.md");
|
|
805
910
|
const existing = fs.existsSync(instructionsPath)
|
|
806
911
|
? fs.readFileSync(instructionsPath, "utf8")
|
|
807
912
|
: "# GitHub Copilot Instructions\n";
|
|
808
|
-
|
|
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");
|
|
809
920
|
const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
|
|
810
921
|
|
|
811
922
|
Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
|
|
812
923
|
|
|
813
|
-
${list || "(
|
|
814
|
-
|
|
924
|
+
${list || "(no front-door prompts to surface)"}`;
|
|
925
|
+
|
|
926
|
+
// User-managed file with the managed block carved out — never doc-stamp.
|
|
927
|
+
|
|
928
|
+
writeFile(instructionsPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
|
|
815
929
|
}
|
|
816
930
|
|
|
817
931
|
// --- VS Code adapter ---
|
|
@@ -839,13 +953,41 @@ function getVSCodeSettingsPaths() {
|
|
|
839
953
|
return candidates.filter(fs.existsSync);
|
|
840
954
|
}
|
|
841
955
|
|
|
842
|
-
function syncVSCode() {
|
|
843
|
-
const settingsPaths = getVSCodeSettingsPaths();
|
|
844
|
-
if (settingsPaths.length === 0) return false;
|
|
845
|
-
|
|
956
|
+
function syncVSCode(targetDir = null) {
|
|
846
957
|
const registryMcp = registry.mcpServers ?? {};
|
|
847
958
|
if (Object.keys(registryMcp).length === 0) return false;
|
|
848
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;
|
|
849
991
|
let synced = false;
|
|
850
992
|
for (const settingsPath of settingsPaths) {
|
|
851
993
|
try {
|
|
@@ -865,40 +1007,64 @@ function syncVSCode() {
|
|
|
865
1007
|
settings["github.copilot.mcpServers"] = mcpServers;
|
|
866
1008
|
if (!DRY_RUN) fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
867
1009
|
synced = true;
|
|
868
|
-
} catch {
|
|
869
|
-
// Skip unreadable settings files
|
|
870
|
-
}
|
|
1010
|
+
} catch { /* unreadable settings file */ }
|
|
871
1011
|
}
|
|
872
1012
|
return synced;
|
|
873
1013
|
}
|
|
874
1014
|
|
|
875
1015
|
// --- Cursor adapter ---
|
|
876
1016
|
|
|
877
|
-
function syncCursor() {
|
|
878
|
-
const cursorMcpPath = path.join(home, ".cursor", "mcp.json");
|
|
879
|
-
if (!fs.existsSync(cursorMcpPath)) return false;
|
|
880
|
-
|
|
1017
|
+
function syncCursor(targetDir = null) {
|
|
881
1018
|
const registryMcp = registry.mcpServers ?? {};
|
|
882
1019
|
if (Object.keys(registryMcp).length === 0) return false;
|
|
883
1020
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
}
|
|
896
1065
|
}
|
|
897
|
-
if (!DRY_RUN) fs.writeFileSync(cursorMcpPath, JSON.stringify(config, null, 2) + "\n");
|
|
898
|
-
return true;
|
|
899
|
-
} catch {
|
|
900
|
-
return false;
|
|
901
1066
|
}
|
|
1067
|
+
return true;
|
|
902
1068
|
}
|
|
903
1069
|
|
|
904
1070
|
// --- OpenCode adapter ---
|
|
@@ -919,14 +1085,28 @@ function opencodeTaskPermissions(entry) {
|
|
|
919
1085
|
};
|
|
920
1086
|
}
|
|
921
1087
|
|
|
922
|
-
function syncOpencode(entries) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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");
|
|
926
1102
|
const managedPluginPath = path.join(pluginsDir, "construct-fallback.js");
|
|
927
1103
|
const toolkitPluginPath = path.join(root, "platforms", "opencode", "plugins", "construct-fallback.js");
|
|
928
1104
|
|
|
929
|
-
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();
|
|
930
1110
|
if (!config.agent) config.agent = {};
|
|
931
1111
|
if (!Array.isArray(config.plugin)) config.plugin = [];
|
|
932
1112
|
config.plugin = config.plugin.filter((entry) => {
|
|
@@ -1010,18 +1190,21 @@ function syncOpencode(entries) {
|
|
|
1010
1190
|
}
|
|
1011
1191
|
}
|
|
1012
1192
|
|
|
1013
|
-
//
|
|
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
|
+
|
|
1014
1196
|
const prefixes = [agentPrefix];
|
|
1015
1197
|
for (const key of Object.keys(config.agent)) {
|
|
1016
1198
|
const isManaged = prefixes.some((p) => key.startsWith(p));
|
|
1017
1199
|
const isOrchestrator = registry.orchestrator?.name === key;
|
|
1018
|
-
if ((isManaged || isOrchestrator) && !
|
|
1200
|
+
if ((isManaged || isOrchestrator) && !writeEntries.find((e) => adapterName(e) === key)) {
|
|
1019
1201
|
delete config.agent[key];
|
|
1020
1202
|
}
|
|
1021
1203
|
}
|
|
1022
1204
|
|
|
1023
|
-
// Write agents — no model/modelFallback set; agents inherit the global model
|
|
1024
|
-
|
|
1205
|
+
// Write agents — no model/modelFallback set; agents inherit the global model.
|
|
1206
|
+
|
|
1207
|
+
for (const entry of writeEntries) {
|
|
1025
1208
|
const name = adapterName(entry);
|
|
1026
1209
|
const perms = opencodePermissions(entry);
|
|
1027
1210
|
config.agent[name] = {
|
|
@@ -1060,12 +1243,16 @@ function syncOpencode(entries) {
|
|
|
1060
1243
|
// --- Slash commands adapter ---
|
|
1061
1244
|
|
|
1062
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
|
+
|
|
1063
1252
|
const sourceCommandsDir = path.join(root, "commands");
|
|
1064
1253
|
if (!fs.existsSync(sourceCommandsDir)) return 0;
|
|
1065
1254
|
|
|
1066
|
-
const claudeCommandsDir = targetDir
|
|
1067
|
-
? path.join(targetDir, ".claude", "commands")
|
|
1068
|
-
: path.join(home, ".claude", "commands");
|
|
1255
|
+
const claudeCommandsDir = path.join(targetDir, ".claude", "commands");
|
|
1069
1256
|
|
|
1070
1257
|
let count = 0;
|
|
1071
1258
|
for (const domain of fs.readdirSync(sourceCommandsDir, { withFileTypes: true })) {
|
|
@@ -1143,32 +1330,35 @@ function collectSkills() {
|
|
|
1143
1330
|
return results;
|
|
1144
1331
|
}
|
|
1145
1332
|
|
|
1146
|
-
/**
|
|
1147
|
-
* Prefix prepended to every generated SKILL.md. The comment must survive
|
|
1148
|
-
* round-trips — it is the canonical signal that a file was produced by sync
|
|
1149
|
-
* and must not be hand-edited.
|
|
1150
|
-
*/
|
|
1151
|
-
const SKILL_GENERATED_PREFIX = "# Generated by construct sync\n\n";
|
|
1152
|
-
|
|
1153
1333
|
/**
|
|
1154
1334
|
* Write collected skills to both .claude/skills/ and .agents/skills/ in
|
|
1155
|
-
* SKILL.md directory format.
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
* and two-phase staging in normal mode produces identical results.
|
|
1335
|
+
* SKILL.md directory format. Each file gets Anthropic Agent Skills frontmatter
|
|
1336
|
+
* (name + description) so the loader can index it. Doc-stamping is opted out
|
|
1337
|
+
* — a doc-stamp YAML block before the real frontmatter produces double-
|
|
1338
|
+
* frontmatter the loader can't parse.
|
|
1160
1339
|
*/
|
|
1161
|
-
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
|
+
|
|
1162
1348
|
const skills = collectSkills();
|
|
1163
1349
|
if (skills.length === 0) return 0;
|
|
1164
1350
|
|
|
1165
|
-
const claudeSkillsDir = path.join(
|
|
1166
|
-
const agentsSkillsDir = path.join(home, ".agents", "skills");
|
|
1351
|
+
const claudeSkillsDir = path.join(targetDir, ".claude", "skills");
|
|
1167
1352
|
|
|
1168
1353
|
for (const { name, content } of skills) {
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1354
|
+
const frontmatter = buildSkillFrontmatter(name, content);
|
|
1355
|
+
|
|
1356
|
+
// Strip any existing frontmatter from the source body so we don't emit two
|
|
1357
|
+
// blocks if a hand-authored skill already carries one.
|
|
1358
|
+
|
|
1359
|
+
const body = stripLeadingFrontmatter(content);
|
|
1360
|
+
const generated = `${frontmatter}\n${body}`;
|
|
1361
|
+
writeFile(path.join(claudeSkillsDir, name, "SKILL.md"), generated, { stamp: false });
|
|
1172
1362
|
}
|
|
1173
1363
|
|
|
1174
1364
|
return skills.length;
|
|
@@ -1211,16 +1401,30 @@ acquireLock();
|
|
|
1211
1401
|
try {
|
|
1212
1402
|
if (projectDir) {
|
|
1213
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);
|
|
1214
1409
|
const cmdCount = syncCommands(projectDir);
|
|
1410
|
+
const skillCount = syncSkills(projectDir);
|
|
1411
|
+
|
|
1215
1412
|
if (DRY_RUN) {
|
|
1216
1413
|
printDryRunDiff();
|
|
1217
1414
|
} else {
|
|
1218
1415
|
commitStaging();
|
|
1219
|
-
|
|
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}).`);
|
|
1220
1425
|
}
|
|
1221
1426
|
} else {
|
|
1222
1427
|
const personaCount = entries.filter((e) => e.isOrchestrator).length;
|
|
1223
|
-
const specialistCount = entries.filter((e) => !e.isOrchestrator).length;
|
|
1224
1428
|
|
|
1225
1429
|
syncCodex(entries);
|
|
1226
1430
|
syncClaude(entries);
|
|
@@ -1228,14 +1432,13 @@ try {
|
|
|
1228
1432
|
const opencodeOk = syncOpencode(entries);
|
|
1229
1433
|
const vscodeOk = syncVSCode();
|
|
1230
1434
|
const cursorOk = syncCursor();
|
|
1231
|
-
|
|
1232
|
-
|
|
1435
|
+
syncCommands();
|
|
1436
|
+
syncSkills();
|
|
1233
1437
|
|
|
1234
1438
|
if (DRY_RUN) {
|
|
1235
1439
|
printDryRunDiff();
|
|
1236
1440
|
} else {
|
|
1237
1441
|
commitStaging();
|
|
1238
|
-
|
|
1239
1442
|
const targets = [
|
|
1240
1443
|
"Codex",
|
|
1241
1444
|
"Claude Code",
|
|
@@ -1244,9 +1447,8 @@ try {
|
|
|
1244
1447
|
vscodeOk && "VS Code",
|
|
1245
1448
|
cursorOk && "Cursor",
|
|
1246
1449
|
].filter(Boolean).join(", ");
|
|
1247
|
-
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.`);
|
|
1248
1451
|
|
|
1249
|
-
// Regenerate shell completions so new commands are immediately tab-completable
|
|
1250
1452
|
const completionsDir = generateCompletions();
|
|
1251
1453
|
if (completionsDir) {
|
|
1252
1454
|
console.log(`Completions updated → ${completionsDir}`);
|