facult 2.7.3 → 2.7.7

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/src/manage.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
+ appendFile,
3
4
  cp,
4
5
  lstat,
5
6
  mkdir,
@@ -83,6 +84,29 @@ export interface ManagedState {
83
84
  tools: Record<string, ManagedToolState>;
84
85
  }
85
86
 
87
+ type SyncLedgerAction = "add" | "adopt" | "remove" | "skip" | "write";
88
+
89
+ interface SyncLedgerEvent {
90
+ version: 1;
91
+ id: string;
92
+ correlationId: string;
93
+ ts: string;
94
+ command: "sync";
95
+ phase: "plan" | "apply";
96
+ dryRun: boolean;
97
+ tool: string;
98
+ rootDir: string;
99
+ scope: "global" | "project";
100
+ actor: string;
101
+ action: SyncLedgerAction;
102
+ targetPath?: string;
103
+ name?: string;
104
+ sourcePath?: string;
105
+ oldHash?: string;
106
+ newHash?: string;
107
+ reason: string;
108
+ }
109
+
86
110
  export interface ToolPaths {
87
111
  tool: string;
88
112
  skillsDir?: string;
@@ -186,6 +210,51 @@ async function ensureDir(p: string) {
186
210
  await mkdir(p, { recursive: true });
187
211
  }
188
212
 
213
+ async function hasCanonicalSource(rootDir: string): Promise<boolean> {
214
+ const fileCandidates = [
215
+ "config.toml",
216
+ "config.local.toml",
217
+ "AGENTS.global.md",
218
+ "AGENTS.override.global.md",
219
+ ];
220
+ for (const relPath of fileCandidates) {
221
+ if (await fileExists(join(rootDir, relPath))) {
222
+ return true;
223
+ }
224
+ }
225
+
226
+ const dirCandidates = [
227
+ "agents",
228
+ "automations",
229
+ "instructions",
230
+ "mcp",
231
+ "rules",
232
+ "skills",
233
+ "snippets",
234
+ "tools",
235
+ ];
236
+ for (const relPath of dirCandidates) {
237
+ const entries = await readdir(join(rootDir, relPath)).catch(
238
+ () => [] as string[]
239
+ );
240
+ if (entries.some((entry) => !entry.startsWith("."))) {
241
+ return true;
242
+ }
243
+ }
244
+
245
+ return false;
246
+ }
247
+
248
+ async function isGeneratedOnlyProjectRoot(args: {
249
+ homeDir: string;
250
+ rootDir: string;
251
+ }): Promise<boolean> {
252
+ if (projectRootFromAiRoot(args.rootDir, args.homeDir) == null) {
253
+ return false;
254
+ }
255
+ return !(await hasCanonicalSource(args.rootDir));
256
+ }
257
+
189
258
  function renderedSourceKindForPath(
190
259
  sourcePath: string
191
260
  ): ManagedRenderedTargetState["sourceKind"] {
@@ -414,13 +483,7 @@ async function resolveToolPaths(
414
483
  return base;
415
484
  }
416
485
 
417
- const adapterPaths = getAdapter("codex")?.getDefaultPaths?.();
418
- const adapterConfig = adapterPaths?.config
419
- ? expandHomePath(adapterPaths.config, home)
420
- : null;
421
-
422
486
  const candidates = [
423
- adapterConfig,
424
487
  homePath(home, ".config", "openai", "codex.json"),
425
488
  homePath(home, ".codex", "config.json"),
426
489
  homePath(home, ".codex", "mcp.json"),
@@ -446,6 +509,30 @@ export function managedStatePathForRoot(
446
509
  return join(facultMachineStateDir(home, rootDir), "managed.json");
447
510
  }
448
511
 
512
+ function syncLedgerPathForRoot(
513
+ home: string = homedir(),
514
+ rootDir?: string
515
+ ): string {
516
+ return join(facultMachineStateDir(home, rootDir), "ledger", "sync.jsonl");
517
+ }
518
+
519
+ async function appendSyncLedgerEvents(args: {
520
+ homeDir: string;
521
+ rootDir: string;
522
+ events: SyncLedgerEvent[];
523
+ }) {
524
+ if (args.events.length === 0) {
525
+ return;
526
+ }
527
+ const pathValue = syncLedgerPathForRoot(args.homeDir, args.rootDir);
528
+ await ensureDir(dirname(pathValue));
529
+ await appendFile(
530
+ pathValue,
531
+ `${args.events.map((event) => JSON.stringify(event)).join("\n")}\n`,
532
+ "utf8"
533
+ );
534
+ }
535
+
449
536
  function legacyManagedStatePathForRoot(
450
537
  home: string = homedir(),
451
538
  rootDir?: string
@@ -720,6 +807,23 @@ async function loadCanonicalAutomations(
720
807
  return await loadAutomationEntries(join(rootDir, "automations"));
721
808
  }
722
809
 
810
+ async function loadRenderableCanonicalAutomations(args: {
811
+ homeDir: string;
812
+ rootDir: string;
813
+ tool: string;
814
+ }): Promise<AutomationEntry[]> {
815
+ const policy = await loadProjectToolSyncPolicy(args);
816
+ const automations = await loadCanonicalAutomations(args.rootDir);
817
+ if (!policy) {
818
+ return automations;
819
+ }
820
+ return automations.filter(
821
+ (automation) =>
822
+ policy.automations.includes("*") ||
823
+ policy.automations.includes(automation.name)
824
+ );
825
+ }
826
+
723
827
  function isAutomationRuntimeRelativePath(relPath: string): boolean {
724
828
  return relPath === "memory.md";
725
829
  }
@@ -839,7 +943,12 @@ async function loadCanonicalCodexMarketplaceText(
839
943
  }
840
944
 
841
945
  async function hashDirectoryTree(root: string): Promise<string | null> {
842
- if (!(await fileExists(root))) {
946
+ try {
947
+ const st = await lstat(root);
948
+ if (!st.isDirectory()) {
949
+ return null;
950
+ }
951
+ } catch {
843
952
  return null;
844
953
  }
845
954
  const files = await listRelativeFilesWithDotfiles(root);
@@ -1082,7 +1191,9 @@ async function syncAgentFiles({
1082
1191
 
1083
1192
  async function planAutomationFileChanges(args: {
1084
1193
  automationDir: string;
1194
+ homeDir: string;
1085
1195
  rootDir: string;
1196
+ tool: string;
1086
1197
  previouslyManagedTargets?: string[];
1087
1198
  }): Promise<{
1088
1199
  add: string[];
@@ -1090,7 +1201,7 @@ async function planAutomationFileChanges(args: {
1090
1201
  contents: Map<string, string>;
1091
1202
  sources: Map<string, string>;
1092
1203
  }> {
1093
- const automations = await loadCanonicalAutomations(args.rootDir);
1204
+ const automations = await loadRenderableCanonicalAutomations(args);
1094
1205
  const contents = new Map<string, string>();
1095
1206
  const sources = new Map<string, string>();
1096
1207
  const desiredPaths = new Set<string>();
@@ -1640,13 +1751,15 @@ async function adoptExistingToolAgents(args: {
1640
1751
  }
1641
1752
 
1642
1753
  async function planExistingAutomationAdoption(args: {
1754
+ homeDir: string;
1643
1755
  rootDir: string;
1756
+ tool: string;
1644
1757
  automationDir: string;
1645
1758
  }): Promise<ExistingManagedImportPlan> {
1646
1759
  const plan = emptyManagedImportPlan();
1647
1760
  const liveAutomations = await loadAutomationEntries(args.automationDir);
1648
1761
  const canonicalAutomations = new Map(
1649
- (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1762
+ (await loadRenderableCanonicalAutomations(args)).map((entry) => [
1650
1763
  entry.name,
1651
1764
  entry,
1652
1765
  ])
@@ -1674,7 +1787,9 @@ async function planExistingAutomationAdoption(args: {
1674
1787
  }
1675
1788
 
1676
1789
  async function adoptExistingAutomations(args: {
1790
+ homeDir: string;
1677
1791
  rootDir: string;
1792
+ tool: string;
1678
1793
  automationDir: string;
1679
1794
  conflictMode: "keep-canonical" | "keep-existing";
1680
1795
  }): Promise<ExistingManagedItem[]> {
@@ -1685,7 +1800,7 @@ async function adoptExistingAutomations(args: {
1685
1800
  const adopted: ExistingManagedItem[] = [];
1686
1801
  const liveAutomations = await loadAutomationEntries(args.automationDir);
1687
1802
  const canonicalAutomations = new Map(
1688
- (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1803
+ (await loadRenderableCanonicalAutomations(args)).map((entry) => [
1689
1804
  entry.name,
1690
1805
  entry,
1691
1806
  ])
@@ -2136,6 +2251,20 @@ function isPreservedToolSkillEntry(name: string): boolean {
2136
2251
  return name.startsWith(".");
2137
2252
  }
2138
2253
 
2254
+ interface SkillSymlinkConflict {
2255
+ name: string;
2256
+ livePath: string;
2257
+ canonicalPath?: string;
2258
+ reason: "modified" | "unmanaged" | "disabled";
2259
+ }
2260
+
2261
+ interface SkillSymlinkPlan {
2262
+ add: string[];
2263
+ remove: string[];
2264
+ conflicts: SkillSymlinkConflict[];
2265
+ adopted: SkillSymlinkConflict[];
2266
+ }
2267
+
2139
2268
  async function restorePreservedToolSkillEntries({
2140
2269
  backupDir,
2141
2270
  toolSkillsDir,
@@ -2170,7 +2299,7 @@ async function planSkillSymlinkChanges({
2170
2299
  toolSkillsDir: string;
2171
2300
  rootDir: string;
2172
2301
  tool: string;
2173
- }): Promise<{ add: string[]; remove: string[] }> {
2302
+ }): Promise<SkillSymlinkPlan> {
2174
2303
  const desiredEntries = await loadEnabledSkillEntries({
2175
2304
  homeDir,
2176
2305
  rootDir,
@@ -2186,16 +2315,46 @@ async function planSkillSymlinkChanges({
2186
2315
 
2187
2316
  const remove: string[] = [];
2188
2317
  const add: string[] = [];
2318
+ const conflicts: SkillSymlinkConflict[] = [];
2189
2319
 
2190
2320
  for (const entry of existing) {
2191
2321
  if (isPreservedToolSkillEntry(entry.name)) {
2192
2322
  continue;
2193
2323
  }
2324
+ const linkPath = join(toolSkillsDir, entry.name);
2194
2325
  if (!desiredSet.has(entry.name)) {
2326
+ if (
2327
+ entry.isDirectory() &&
2328
+ (await fileExists(join(linkPath, "SKILL.md")))
2329
+ ) {
2330
+ const disabledCanonicalPath = join(rootDir, "skills", entry.name);
2331
+ const [liveHash, canonicalHash] = await Promise.all([
2332
+ hashDirectoryTree(linkPath),
2333
+ hashDirectoryTree(disabledCanonicalPath),
2334
+ ]);
2335
+ if (canonicalHash != null) {
2336
+ if (liveHash === canonicalHash) {
2337
+ remove.push(entry.name);
2338
+ continue;
2339
+ }
2340
+ conflicts.push({
2341
+ name: entry.name,
2342
+ livePath: linkPath,
2343
+ canonicalPath: disabledCanonicalPath,
2344
+ reason: "disabled",
2345
+ });
2346
+ continue;
2347
+ }
2348
+ conflicts.push({
2349
+ name: entry.name,
2350
+ livePath: linkPath,
2351
+ reason: "unmanaged",
2352
+ });
2353
+ continue;
2354
+ }
2195
2355
  remove.push(entry.name);
2196
2356
  continue;
2197
2357
  }
2198
- const linkPath = join(toolSkillsDir, entry.name);
2199
2358
  const target = desiredTargets.get(entry.name);
2200
2359
  if (!target) {
2201
2360
  remove.push(entry.name);
@@ -2204,6 +2363,27 @@ async function planSkillSymlinkChanges({
2204
2363
  try {
2205
2364
  const st = await lstat(linkPath);
2206
2365
  if (!st.isSymbolicLink()) {
2366
+ if (
2367
+ st.isDirectory() &&
2368
+ (await fileExists(join(linkPath, "SKILL.md")))
2369
+ ) {
2370
+ const [liveHash, canonicalHash] = await Promise.all([
2371
+ hashDirectoryTree(linkPath),
2372
+ hashDirectoryTree(target),
2373
+ ]);
2374
+ if (
2375
+ liveHash != null &&
2376
+ (canonicalHash == null || liveHash !== canonicalHash)
2377
+ ) {
2378
+ conflicts.push({
2379
+ name: entry.name,
2380
+ livePath: linkPath,
2381
+ canonicalPath: target,
2382
+ reason: canonicalHash == null ? "unmanaged" : "modified",
2383
+ });
2384
+ continue;
2385
+ }
2386
+ }
2207
2387
  remove.push(entry.name);
2208
2388
  add.push(entry.name);
2209
2389
  continue;
@@ -2230,6 +2410,8 @@ async function planSkillSymlinkChanges({
2230
2410
  return {
2231
2411
  add: Array.from(new Set(add)).sort(),
2232
2412
  remove: Array.from(new Set(remove)).sort(),
2413
+ conflicts: conflicts.sort((a, b) => a.name.localeCompare(b.name)),
2414
+ adopted: [],
2233
2415
  };
2234
2416
  }
2235
2417
 
@@ -2245,7 +2427,7 @@ async function syncSkillSymlinks({
2245
2427
  rootDir: string;
2246
2428
  tool: string;
2247
2429
  dryRun?: boolean;
2248
- }): Promise<{ add: string[]; remove: string[] }> {
2430
+ }): Promise<SkillSymlinkPlan> {
2249
2431
  const plan = await planSkillSymlinkChanges({
2250
2432
  homeDir,
2251
2433
  toolSkillsDir,
@@ -2256,6 +2438,23 @@ async function syncSkillSymlinks({
2256
2438
  return plan;
2257
2439
  }
2258
2440
 
2441
+ const adoptedConflicts = await adoptSkillSymlinkConflictsIntoCanonical({
2442
+ rootDir,
2443
+ conflicts: plan.conflicts,
2444
+ });
2445
+ const adoptedNames = new Set(
2446
+ adoptedConflicts.map((conflict) => conflict.name)
2447
+ );
2448
+ const unresolvedConflicts = plan.conflicts.filter(
2449
+ (conflict) => !adoptedNames.has(conflict.name)
2450
+ );
2451
+ if (adoptedConflicts.length > 0) {
2452
+ await buildIndex({
2453
+ homeDir,
2454
+ rootDir,
2455
+ force: false,
2456
+ });
2457
+ }
2259
2458
  const desiredSkills = new Map(
2260
2459
  (
2261
2460
  await loadEnabledSkillEntries({
@@ -2267,19 +2466,30 @@ async function syncSkillSymlinks({
2267
2466
  );
2268
2467
 
2269
2468
  await ensureDir(toolSkillsDir);
2270
- for (const name of plan.remove) {
2469
+ const remove = Array.from(
2470
+ new Set([...plan.remove, ...adoptedConflicts.map(({ name }) => name)])
2471
+ ).sort();
2472
+ const add = Array.from(
2473
+ new Set([...plan.add, ...adoptedConflicts.map(({ name }) => name)])
2474
+ ).sort();
2475
+ for (const name of remove) {
2271
2476
  const linkPath = join(toolSkillsDir, name);
2272
2477
  await rm(linkPath, { recursive: true, force: true });
2273
2478
  }
2274
- for (const name of plan.add) {
2275
- const target = desiredSkills.get(name);
2479
+ for (const name of add) {
2480
+ const target = desiredSkills.get(name) ?? join(rootDir, "skills", name);
2276
2481
  if (!(target && (await fileExists(target)))) {
2277
2482
  continue;
2278
2483
  }
2279
2484
  const linkPath = join(toolSkillsDir, name);
2280
2485
  await symlink(target, linkPath, "dir");
2281
2486
  }
2282
- return plan;
2487
+ return {
2488
+ add,
2489
+ remove,
2490
+ conflicts: unresolvedConflicts,
2491
+ adopted: adoptedConflicts,
2492
+ };
2283
2493
  }
2284
2494
 
2285
2495
  async function planMcpWrite({
@@ -2292,10 +2502,14 @@ async function planMcpWrite({
2292
2502
  mcpConfigPath: string;
2293
2503
  rootDir: string;
2294
2504
  tool: string;
2295
- }): Promise<{ needsWrite: boolean; contents: string }> {
2296
- const { servers } = await loadCanonicalMcpState(rootDir, {
2297
- includeLocal: true,
2298
- });
2505
+ }): Promise<{ needsWrite: boolean; contents: string; sourcePath: string }> {
2506
+ const { localPath, servers, trackedPath } = await loadCanonicalMcpState(
2507
+ rootDir,
2508
+ {
2509
+ includeLocal: true,
2510
+ }
2511
+ );
2512
+ const sourcePath = (await fileExists(localPath)) ? localPath : trackedPath;
2299
2513
  const filtered = await filterServersForTool({
2300
2514
  homeDir,
2301
2515
  rootDir,
@@ -2305,38 +2519,40 @@ async function planMcpWrite({
2305
2519
  const contents = `${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`;
2306
2520
 
2307
2521
  if (!(await fileExists(mcpConfigPath))) {
2308
- return { needsWrite: true, contents };
2522
+ return {
2523
+ needsWrite: true,
2524
+ contents,
2525
+ sourcePath,
2526
+ };
2309
2527
  }
2310
2528
  try {
2311
2529
  const current = await Bun.file(mcpConfigPath).text();
2312
- return { needsWrite: current !== contents, contents };
2530
+ return {
2531
+ needsWrite: current !== contents,
2532
+ contents,
2533
+ sourcePath,
2534
+ };
2313
2535
  } catch {
2314
- return { needsWrite: true, contents };
2536
+ return {
2537
+ needsWrite: true,
2538
+ contents,
2539
+ sourcePath,
2540
+ };
2315
2541
  }
2316
2542
  }
2317
2543
 
2318
- async function syncMcpConfig({
2319
- homeDir,
2320
- mcpConfigPath,
2321
- rootDir,
2322
- tool,
2323
- dryRun,
2324
- }: {
2325
- homeDir: string;
2326
- mcpConfigPath: string;
2327
- rootDir: string;
2328
- tool: string;
2329
- dryRun?: boolean;
2330
- }): Promise<{ needsWrite: boolean }> {
2331
- const plan = await planMcpWrite({ homeDir, mcpConfigPath, rootDir, tool });
2332
- if (dryRun) {
2333
- return { needsWrite: plan.needsWrite };
2544
+ async function isEmptyGeneratedMcpConfig(pathValue: string): Promise<boolean> {
2545
+ const text = await readTextIfExists(pathValue);
2546
+ if (text == null) {
2547
+ return false;
2334
2548
  }
2335
- if (plan.needsWrite) {
2336
- await ensureDir(dirname(mcpConfigPath));
2337
- await Bun.write(mcpConfigPath, plan.contents);
2549
+ try {
2550
+ const parsed = JSON.parse(text) as unknown;
2551
+ const servers = extractServersObject(parsed);
2552
+ return servers != null && Object.keys(servers).length === 0;
2553
+ } catch {
2554
+ return false;
2338
2555
  }
2339
- return { needsWrite: plan.needsWrite };
2340
2556
  }
2341
2557
 
2342
2558
  async function writeToolMcpConfig({
@@ -2417,7 +2633,9 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2417
2633
  : emptyManagedImportPlan(),
2418
2634
  toolPaths.automationDir
2419
2635
  ? await planExistingAutomationAdoption({
2636
+ homeDir: home,
2420
2637
  rootDir,
2638
+ tool,
2421
2639
  automationDir: toolPaths.automationDir,
2422
2640
  })
2423
2641
  : emptyManagedImportPlan(),
@@ -2577,7 +2795,9 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2577
2795
  }
2578
2796
  if (toolPaths.automationDir && opts.adoptExisting) {
2579
2797
  const result = await adoptExistingAutomations({
2798
+ homeDir: home,
2580
2799
  rootDir,
2800
+ tool,
2581
2801
  automationDir: toolPaths.automationDir,
2582
2802
  conflictMode: importConflictMode,
2583
2803
  });
@@ -2648,10 +2868,20 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2648
2868
  tool,
2649
2869
  })
2650
2870
  : null;
2871
+ const mcpPreview = toolPaths.mcpConfig
2872
+ ? await planMcpWrite({
2873
+ homeDir: home,
2874
+ mcpConfigPath: toolPaths.mcpConfig,
2875
+ rootDir,
2876
+ tool,
2877
+ })
2878
+ : null;
2651
2879
  const automationPreview = toolPaths.automationDir
2652
2880
  ? await planAutomationFileChanges({
2653
2881
  automationDir: toolPaths.automationDir,
2882
+ homeDir: home,
2654
2883
  rootDir,
2884
+ tool,
2655
2885
  })
2656
2886
  : null;
2657
2887
  const globalDocsPreview = toolPaths.toolHome
@@ -2927,6 +3157,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2927
3157
  ),
2928
3158
  });
2929
3159
  }
3160
+ if (toolPaths.mcpConfig && mcpPreview) {
3161
+ updateRenderedTargetState({
3162
+ entry: managedEntry,
3163
+ writtenTargets: [toolPaths.mcpConfig],
3164
+ removedTargets: [],
3165
+ contents: new Map([[toolPaths.mcpConfig, mcpPreview.contents]]),
3166
+ sources: new Map([[toolPaths.mcpConfig, mcpPreview.sourcePath]]),
3167
+ });
3168
+ }
2930
3169
 
2931
3170
  if (pluginPreview) {
2932
3171
  updateRenderedTargetState({
@@ -3248,7 +3487,7 @@ interface RenderedConflict {
3248
3487
  targetPath: string;
3249
3488
  sourcePath: string;
3250
3489
  sourceKind: ManagedRenderedTargetState["sourceKind"];
3251
- reason: "modified" | "unknown_state";
3490
+ reason: "modified" | "unknown_state" | "missing_source";
3252
3491
  }
3253
3492
 
3254
3493
  interface RenderedApplyPlan {
@@ -3265,16 +3504,9 @@ async function planRenderedTargetConflicts(args: {
3265
3504
  desiredSources: Map<string, string>;
3266
3505
  conflictMode?: "warn" | "overwrite";
3267
3506
  protectAllSources?: boolean;
3507
+ protectMissingSources?: boolean;
3268
3508
  normalizeText?: boolean;
3269
3509
  }): Promise<RenderedApplyPlan> {
3270
- if (args.conflictMode === "overwrite") {
3271
- return {
3272
- write: args.desiredWrites,
3273
- remove: args.desiredRemoves,
3274
- conflicts: [],
3275
- };
3276
- }
3277
-
3278
3510
  const previous = args.entry.renderedTargets ?? {};
3279
3511
  const write: string[] = [];
3280
3512
  const remove: string[] = [];
@@ -3293,7 +3525,15 @@ async function planRenderedTargetConflicts(args: {
3293
3525
  continue;
3294
3526
  }
3295
3527
  const sourceKind = renderedSourceKindForPath(sourcePath);
3296
- if (sourceKind !== "builtin" && !args.protectAllSources) {
3528
+ if (args.conflictMode === "overwrite" && sourceKind === "builtin") {
3529
+ if (args.desiredWrites.includes(targetPath)) {
3530
+ write.push(targetPath);
3531
+ } else {
3532
+ remove.push(targetPath);
3533
+ }
3534
+ continue;
3535
+ }
3536
+ if (sourceKind !== "builtin" && args.protectAllSources === false) {
3297
3537
  if (args.desiredWrites.includes(targetPath)) {
3298
3538
  write.push(targetPath);
3299
3539
  } else {
@@ -3303,6 +3543,20 @@ async function planRenderedTargetConflicts(args: {
3303
3543
  }
3304
3544
 
3305
3545
  const prior = previous[targetPath];
3546
+ if (
3547
+ args.protectMissingSources &&
3548
+ args.desiredRemoves.includes(targetPath) &&
3549
+ prior?.sourcePath &&
3550
+ !(await fileExists(prior.sourcePath))
3551
+ ) {
3552
+ conflicts.push({
3553
+ targetPath,
3554
+ sourcePath: prior.sourcePath,
3555
+ sourceKind,
3556
+ reason: "missing_source",
3557
+ });
3558
+ continue;
3559
+ }
3306
3560
  const currentHash = await readTargetHash(targetPath, {
3307
3561
  normalizeText: args.normalizeText,
3308
3562
  });
@@ -3372,9 +3626,11 @@ function logRenderedConflicts(
3372
3626
  for (const conflict of conflicts) {
3373
3627
  const verb = dryRun ? "would skip" : "skipped";
3374
3628
  const state =
3375
- conflict.reason === "unknown_state"
3376
- ? "no prior managed hash is recorded"
3377
- : "local edits were detected";
3629
+ conflict.reason === "missing_source"
3630
+ ? `canonical source is missing at ${conflict.sourcePath}`
3631
+ : conflict.reason === "unknown_state"
3632
+ ? "no prior managed hash is recorded"
3633
+ : "local edits were detected";
3378
3634
  const surface =
3379
3635
  conflict.sourceKind === "builtin"
3380
3636
  ? "builtin-backed target"
@@ -3387,6 +3643,57 @@ function logRenderedConflicts(
3387
3643
  }
3388
3644
  }
3389
3645
 
3646
+ function logSkillSymlinkConflicts(
3647
+ tool: string,
3648
+ conflicts: SkillSymlinkConflict[],
3649
+ dryRun?: boolean
3650
+ ) {
3651
+ for (const conflict of conflicts) {
3652
+ const verb =
3653
+ conflict.reason === "disabled"
3654
+ ? dryRun
3655
+ ? "would preserve"
3656
+ : "preserved"
3657
+ : dryRun
3658
+ ? "would adopt"
3659
+ : "adopted";
3660
+ const state =
3661
+ conflict.reason === "unmanaged"
3662
+ ? "it is not in canonical skill state"
3663
+ : conflict.reason === "disabled"
3664
+ ? "it maps to a disabled canonical skill"
3665
+ : "local skill content differs from canonical state";
3666
+ const canonical = conflict.canonicalPath
3667
+ ? ` (canonical ${conflict.canonicalPath})`
3668
+ : "";
3669
+ console.warn(
3670
+ `${tool}: ${verb} skill ${conflict.name} from ${conflict.livePath}${canonical} because ${state}.`
3671
+ );
3672
+ }
3673
+ }
3674
+
3675
+ async function adoptSkillSymlinkConflictsIntoCanonical(args: {
3676
+ rootDir: string;
3677
+ conflicts: SkillSymlinkConflict[];
3678
+ }): Promise<SkillSymlinkConflict[]> {
3679
+ const adopted: SkillSymlinkConflict[] = [];
3680
+ for (const conflict of args.conflicts) {
3681
+ if (conflict.reason === "disabled") {
3682
+ continue;
3683
+ }
3684
+ const canonicalPath =
3685
+ conflict.canonicalPath ?? join(args.rootDir, "skills", conflict.name);
3686
+ if (!(await fileExists(join(conflict.livePath, "SKILL.md")))) {
3687
+ continue;
3688
+ }
3689
+ await rm(canonicalPath, { recursive: true, force: true });
3690
+ await ensureDir(dirname(canonicalPath));
3691
+ await cp(conflict.livePath, canonicalPath, { recursive: true });
3692
+ adopted.push({ ...conflict, canonicalPath });
3693
+ }
3694
+ return adopted.sort((a, b) => a.name.localeCompare(b.name));
3695
+ }
3696
+
3390
3697
  async function applyRenderedWrites(args: {
3391
3698
  contents: Map<string, ManagedTargetContent>;
3392
3699
  targets: string[];
@@ -3485,9 +3792,9 @@ function pruneAutomationRuntimeRenderedTargets(args: {
3485
3792
 
3486
3793
  function logSyncDryRun({
3487
3794
  tool,
3488
- entry,
3489
3795
  skillPlan,
3490
3796
  mcpPlan,
3797
+ mcpConflicts,
3491
3798
  agentPlan,
3492
3799
  agentConflicts,
3493
3800
  automationPlan,
@@ -3502,9 +3809,9 @@ function logSyncDryRun({
3502
3809
  pluginConflicts,
3503
3810
  }: {
3504
3811
  tool: string;
3505
- entry: ManagedToolState;
3506
- skillPlan: { add: string[]; remove: string[] };
3507
- mcpPlan: { needsWrite: boolean };
3812
+ skillPlan: SkillSymlinkPlan;
3813
+ mcpPlan: { write: boolean; targetPath: string };
3814
+ mcpConflicts: RenderedConflict[];
3508
3815
  agentPlan: { add: string[]; remove: string[] };
3509
3816
  agentConflicts: RenderedConflict[];
3510
3817
  automationPlan: { write: string[]; remove: string[] };
@@ -3524,6 +3831,7 @@ function logSyncDryRun({
3524
3831
  for (const name of skillPlan.remove) {
3525
3832
  console.log(`${tool}: would remove skill ${name}`);
3526
3833
  }
3834
+ logSkillSymlinkConflicts(tool, skillPlan.conflicts, true);
3527
3835
  for (const p of agentPlan.add) {
3528
3836
  console.log(`${tool}: would write agent ${p}`);
3529
3837
  }
@@ -3566,12 +3874,14 @@ function logSyncDryRun({
3566
3874
  console.log(`${tool}: would remove plugin asset ${p}`);
3567
3875
  }
3568
3876
  logRenderedConflicts(tool, pluginConflicts, true);
3569
- if (mcpPlan.needsWrite && entry.mcpConfig) {
3570
- console.log(`${tool}: would update mcp config ${entry.mcpConfig}`);
3877
+ if (mcpPlan.write) {
3878
+ console.log(`${tool}: would update mcp config ${mcpPlan.targetPath}`);
3571
3879
  }
3880
+ logRenderedConflicts(tool, mcpConflicts, true);
3572
3881
  if (
3573
3882
  skillPlan.add.length === 0 &&
3574
3883
  skillPlan.remove.length === 0 &&
3884
+ skillPlan.conflicts.length === 0 &&
3575
3885
  agentPlan.add.length === 0 &&
3576
3886
  agentPlan.remove.length === 0 &&
3577
3887
  automationPlan.write.length === 0 &&
@@ -3584,7 +3894,8 @@ function logSyncDryRun({
3584
3894
  !configPlan.remove &&
3585
3895
  pluginPlan.write.length === 0 &&
3586
3896
  pluginPlan.remove.length === 0 &&
3587
- !mcpPlan.needsWrite &&
3897
+ !mcpPlan.write &&
3898
+ mcpConflicts.length === 0 &&
3588
3899
  agentConflicts.length === 0 &&
3589
3900
  automationConflicts.length === 0 &&
3590
3901
  globalDocsConflicts.length === 0 &&
@@ -3596,6 +3907,171 @@ function logSyncDryRun({
3596
3907
  }
3597
3908
  }
3598
3909
 
3910
+ function syncLedgerBase(args: {
3911
+ correlationId: string;
3912
+ dryRun: boolean;
3913
+ homeDir: string;
3914
+ rootDir: string;
3915
+ tool: string;
3916
+ }): Omit<
3917
+ SyncLedgerEvent,
3918
+ | "action"
3919
+ | "id"
3920
+ | "name"
3921
+ | "newHash"
3922
+ | "oldHash"
3923
+ | "reason"
3924
+ | "sourcePath"
3925
+ | "targetPath"
3926
+ > {
3927
+ return {
3928
+ version: 1,
3929
+ correlationId: args.correlationId,
3930
+ ts: new Date().toISOString(),
3931
+ command: "sync",
3932
+ phase: args.dryRun ? "plan" : "apply",
3933
+ dryRun: args.dryRun,
3934
+ tool: args.tool,
3935
+ rootDir: args.rootDir,
3936
+ scope: projectRootFromAiRoot(args.rootDir, args.homeDir)
3937
+ ? "project"
3938
+ : "global",
3939
+ actor: process.env.USER ?? "unknown",
3940
+ };
3941
+ }
3942
+
3943
+ function renderedDesiredHash(args: {
3944
+ contents: Map<string, ManagedTargetContent>;
3945
+ normalizeText?: boolean;
3946
+ targetPath: string;
3947
+ }): string | undefined {
3948
+ const contents = args.contents.get(args.targetPath);
3949
+ return contents
3950
+ ? targetContentHash(contents, { normalizeText: args.normalizeText })
3951
+ : undefined;
3952
+ }
3953
+
3954
+ function collectRenderedLedgerEvents(args: {
3955
+ base: Omit<
3956
+ SyncLedgerEvent,
3957
+ | "action"
3958
+ | "id"
3959
+ | "name"
3960
+ | "newHash"
3961
+ | "oldHash"
3962
+ | "reason"
3963
+ | "sourcePath"
3964
+ | "targetPath"
3965
+ >;
3966
+ conflicts: RenderedConflict[];
3967
+ contents: Map<string, ManagedTargetContent>;
3968
+ entry: ManagedToolState;
3969
+ normalizeText?: boolean;
3970
+ plan: RenderedApplyPlan;
3971
+ reason: string;
3972
+ sources: Map<string, string>;
3973
+ }): SyncLedgerEvent[] {
3974
+ const previous = args.entry.renderedTargets ?? {};
3975
+ const events: SyncLedgerEvent[] = [];
3976
+ for (const targetPath of args.plan.write) {
3977
+ events.push({
3978
+ ...args.base,
3979
+ id: randomUUID(),
3980
+ action: "write",
3981
+ targetPath,
3982
+ sourcePath: args.sources.get(targetPath),
3983
+ oldHash: previous[targetPath]?.hash,
3984
+ newHash: renderedDesiredHash({
3985
+ contents: args.contents,
3986
+ normalizeText: args.normalizeText,
3987
+ targetPath,
3988
+ }),
3989
+ reason: args.reason,
3990
+ });
3991
+ }
3992
+ for (const targetPath of args.plan.remove) {
3993
+ events.push({
3994
+ ...args.base,
3995
+ id: randomUUID(),
3996
+ action: "remove",
3997
+ targetPath,
3998
+ sourcePath: previous[targetPath]?.sourcePath,
3999
+ oldHash: previous[targetPath]?.hash,
4000
+ reason: args.reason,
4001
+ });
4002
+ }
4003
+ for (const conflict of args.conflicts) {
4004
+ events.push({
4005
+ ...args.base,
4006
+ id: randomUUID(),
4007
+ action: "skip",
4008
+ targetPath: conflict.targetPath,
4009
+ sourcePath: conflict.sourcePath,
4010
+ oldHash: previous[conflict.targetPath]?.hash,
4011
+ reason: conflict.reason,
4012
+ });
4013
+ }
4014
+ return events;
4015
+ }
4016
+
4017
+ function collectSkillLedgerEvents(args: {
4018
+ base: Omit<
4019
+ SyncLedgerEvent,
4020
+ | "action"
4021
+ | "id"
4022
+ | "name"
4023
+ | "newHash"
4024
+ | "oldHash"
4025
+ | "reason"
4026
+ | "sourcePath"
4027
+ | "targetPath"
4028
+ >;
4029
+ plan: SkillSymlinkPlan;
4030
+ }): SyncLedgerEvent[] {
4031
+ const events: SyncLedgerEvent[] = [];
4032
+ for (const name of args.plan.add) {
4033
+ events.push({
4034
+ ...args.base,
4035
+ id: randomUUID(),
4036
+ action: "add",
4037
+ name,
4038
+ reason: "skill_symlink",
4039
+ });
4040
+ }
4041
+ for (const name of args.plan.remove) {
4042
+ events.push({
4043
+ ...args.base,
4044
+ id: randomUUID(),
4045
+ action: "remove",
4046
+ name,
4047
+ reason: "skill_symlink",
4048
+ });
4049
+ }
4050
+ for (const conflict of args.plan.conflicts) {
4051
+ events.push({
4052
+ ...args.base,
4053
+ id: randomUUID(),
4054
+ action: "skip",
4055
+ name: conflict.name,
4056
+ targetPath: conflict.livePath,
4057
+ sourcePath: conflict.canonicalPath,
4058
+ reason: `skill_${conflict.reason}`,
4059
+ });
4060
+ }
4061
+ for (const conflict of args.plan.adopted) {
4062
+ events.push({
4063
+ ...args.base,
4064
+ id: randomUUID(),
4065
+ action: "adopt",
4066
+ name: conflict.name,
4067
+ targetPath: conflict.livePath,
4068
+ sourcePath: conflict.canonicalPath,
4069
+ reason: `skill_${conflict.reason}`,
4070
+ });
4071
+ }
4072
+ return events;
4073
+ }
4074
+
3599
4075
  async function repairManagedCanonicalContent(args: {
3600
4076
  homeDir: string;
3601
4077
  rootDir: string;
@@ -4002,6 +4478,38 @@ async function syncManagedToolEntry({
4002
4478
  dryRun?: boolean;
4003
4479
  builtinConflictMode?: "warn" | "overwrite";
4004
4480
  }) {
4481
+ const correlationId = randomUUID();
4482
+ const baseLedger = syncLedgerBase({
4483
+ correlationId,
4484
+ dryRun: Boolean(dryRun),
4485
+ homeDir,
4486
+ rootDir,
4487
+ tool,
4488
+ });
4489
+ if (await isGeneratedOnlyProjectRoot({ homeDir, rootDir })) {
4490
+ const message = `${tool}: ${dryRun ? "would skip" : "skipped"} sync because project .ai contains generated state only and no canonical source. Initialize or restore project .ai source before syncing managed project output.`;
4491
+ if (dryRun) {
4492
+ console.log(message);
4493
+ } else {
4494
+ console.warn(message);
4495
+ }
4496
+ if (!dryRun) {
4497
+ await appendSyncLedgerEvents({
4498
+ homeDir,
4499
+ rootDir,
4500
+ events: [
4501
+ {
4502
+ ...baseLedger,
4503
+ id: randomUUID(),
4504
+ action: "skip",
4505
+ reason: "generated_only_project_root",
4506
+ },
4507
+ ],
4508
+ });
4509
+ }
4510
+ return;
4511
+ }
4512
+
4005
4513
  pruneAutomationRuntimeRenderedTargets({
4006
4514
  entry,
4007
4515
  automationDir: entry.automationDir,
@@ -4024,7 +4532,18 @@ async function syncManagedToolEntry({
4024
4532
  tool,
4025
4533
  dryRun,
4026
4534
  })
4027
- : { add: [], remove: [] };
4535
+ : { add: [], remove: [], conflicts: [], adopted: [] };
4536
+ const skillLedgerEvents = collectSkillLedgerEvents({
4537
+ base: baseLedger,
4538
+ plan: skillPlan,
4539
+ });
4540
+ if (!dryRun) {
4541
+ await appendSyncLedgerEvents({
4542
+ homeDir,
4543
+ rootDir,
4544
+ events: skillLedgerEvents,
4545
+ });
4546
+ }
4028
4547
 
4029
4548
  const agentPlan = entry.agentsDir
4030
4549
  ? await planAgentFileChanges({
@@ -4037,20 +4556,21 @@ async function syncManagedToolEntry({
4037
4556
  const automationPlan = entry.automationDir
4038
4557
  ? await planAutomationFileChanges({
4039
4558
  automationDir: entry.automationDir,
4559
+ homeDir,
4040
4560
  rootDir,
4561
+ tool,
4041
4562
  previouslyManagedTargets: Object.keys(entry.renderedTargets ?? {}),
4042
4563
  })
4043
4564
  : { add: [], remove: [], contents: new Map(), sources: new Map() };
4044
4565
 
4045
4566
  const mcpPlan = entry.mcpConfig
4046
- ? await syncMcpConfig({
4567
+ ? await planMcpWrite({
4047
4568
  homeDir,
4048
4569
  mcpConfigPath: entry.mcpConfig,
4049
4570
  rootDir,
4050
4571
  tool,
4051
- dryRun,
4052
4572
  })
4053
- : { needsWrite: false };
4573
+ : null;
4054
4574
 
4055
4575
  const globalDocsPlan = entry.toolHome
4056
4576
  ? await planToolGlobalDocsSync({
@@ -4113,6 +4633,7 @@ async function syncManagedToolEntry({
4113
4633
  previouslyManagedTargets: Object.keys(entry.renderedTargets ?? {}),
4114
4634
  })
4115
4635
  : { add: [], remove: [], contents: new Map(), sources: new Map() };
4636
+ const protectMissingSources = projectRootFromAiRoot(rootDir, homeDir) != null;
4116
4637
 
4117
4638
  const agentRendered = await planRenderedTargetConflicts({
4118
4639
  entry,
@@ -4121,6 +4642,7 @@ async function syncManagedToolEntry({
4121
4642
  desiredContents: agentPlan.contents,
4122
4643
  desiredSources: agentPlan.sources,
4123
4644
  conflictMode: builtinConflictMode,
4645
+ protectMissingSources,
4124
4646
  });
4125
4647
  const globalDocsRendered = await planRenderedTargetConflicts({
4126
4648
  entry,
@@ -4129,6 +4651,7 @@ async function syncManagedToolEntry({
4129
4651
  desiredContents: globalDocsPlan.contents,
4130
4652
  desiredSources: globalDocsPlan.sources,
4131
4653
  conflictMode: builtinConflictMode,
4654
+ protectMissingSources,
4132
4655
  });
4133
4656
  const automationRendered = await planRenderedTargetConflicts({
4134
4657
  entry,
@@ -4138,6 +4661,7 @@ async function syncManagedToolEntry({
4138
4661
  desiredSources: automationPlan.sources,
4139
4662
  conflictMode: builtinConflictMode,
4140
4663
  protectAllSources: true,
4664
+ protectMissingSources,
4141
4665
  });
4142
4666
  const rulesRendered = await planRenderedTargetConflicts({
4143
4667
  entry,
@@ -4146,6 +4670,7 @@ async function syncManagedToolEntry({
4146
4670
  desiredContents: rulesPlan.contents,
4147
4671
  desiredSources: rulesPlan.sources,
4148
4672
  conflictMode: builtinConflictMode,
4673
+ protectMissingSources,
4149
4674
  });
4150
4675
  const configContents =
4151
4676
  configPlan.contents != null
@@ -4165,6 +4690,7 @@ async function syncManagedToolEntry({
4165
4690
  desiredContents: configContents,
4166
4691
  desiredSources: configSources,
4167
4692
  conflictMode: builtinConflictMode,
4693
+ protectMissingSources,
4168
4694
  });
4169
4695
  const pluginRendered = await planRenderedTargetConflicts({
4170
4696
  entry,
@@ -4175,14 +4701,126 @@ async function syncManagedToolEntry({
4175
4701
  conflictMode: builtinConflictMode,
4176
4702
  protectAllSources: true,
4177
4703
  normalizeText: false,
4704
+ protectMissingSources,
4178
4705
  });
4706
+ const mcpContents =
4707
+ entry.mcpConfig && mcpPlan
4708
+ ? new Map<string, ManagedTargetContent>([
4709
+ [entry.mcpConfig, mcpPlan.contents],
4710
+ ])
4711
+ : new Map<string, ManagedTargetContent>();
4712
+ const mcpSources =
4713
+ entry.mcpConfig && mcpPlan
4714
+ ? new Map<string, string>([[entry.mcpConfig, mcpPlan.sourcePath]])
4715
+ : new Map<string, string>();
4716
+ const mcpRendered = await planRenderedTargetConflicts({
4717
+ entry,
4718
+ desiredWrites:
4719
+ entry.mcpConfig && mcpPlan?.needsWrite ? [entry.mcpConfig] : [],
4720
+ desiredRemoves: [],
4721
+ desiredContents: mcpContents,
4722
+ desiredSources: mcpSources,
4723
+ conflictMode: builtinConflictMode,
4724
+ protectMissingSources,
4725
+ });
4726
+ if (
4727
+ entry.mcpConfig &&
4728
+ mcpRendered.conflicts.some(
4729
+ (conflict) =>
4730
+ conflict.targetPath === entry.mcpConfig &&
4731
+ conflict.reason === "unknown_state"
4732
+ ) &&
4733
+ (await isEmptyGeneratedMcpConfig(entry.mcpConfig))
4734
+ ) {
4735
+ mcpRendered.conflicts = mcpRendered.conflicts.filter(
4736
+ (conflict) => conflict.targetPath !== entry.mcpConfig
4737
+ );
4738
+ mcpRendered.write.push(entry.mcpConfig);
4739
+ mcpRendered.write.sort();
4740
+ }
4741
+
4742
+ const ledgerEvents = [
4743
+ ...collectRenderedLedgerEvents({
4744
+ base: baseLedger,
4745
+ conflicts: agentRendered.conflicts,
4746
+ contents: agentPlan.contents,
4747
+ entry,
4748
+ normalizeText: true,
4749
+ plan: agentRendered,
4750
+ reason: "agent_render",
4751
+ sources: agentPlan.sources,
4752
+ }),
4753
+ ...collectRenderedLedgerEvents({
4754
+ base: baseLedger,
4755
+ conflicts: automationRendered.conflicts,
4756
+ contents: automationPlan.contents,
4757
+ entry,
4758
+ normalizeText: true,
4759
+ plan: automationRendered,
4760
+ reason: "automation_render",
4761
+ sources: automationPlan.sources,
4762
+ }),
4763
+ ...collectRenderedLedgerEvents({
4764
+ base: baseLedger,
4765
+ conflicts: globalDocsRendered.conflicts,
4766
+ contents: globalDocsPlan.contents,
4767
+ entry,
4768
+ normalizeText: true,
4769
+ plan: globalDocsRendered,
4770
+ reason: "global_doc_render",
4771
+ sources: globalDocsPlan.sources,
4772
+ }),
4773
+ ...collectRenderedLedgerEvents({
4774
+ base: baseLedger,
4775
+ conflicts: rulesRendered.conflicts,
4776
+ contents: rulesPlan.contents,
4777
+ entry,
4778
+ normalizeText: true,
4779
+ plan: rulesRendered,
4780
+ reason: "rule_render",
4781
+ sources: rulesPlan.sources,
4782
+ }),
4783
+ ...collectRenderedLedgerEvents({
4784
+ base: baseLedger,
4785
+ conflicts: configRendered.conflicts,
4786
+ contents: configContents,
4787
+ entry,
4788
+ normalizeText: true,
4789
+ plan: configRendered,
4790
+ reason: "tool_config_render",
4791
+ sources: configSources,
4792
+ }),
4793
+ ...collectRenderedLedgerEvents({
4794
+ base: baseLedger,
4795
+ conflicts: mcpRendered.conflicts,
4796
+ contents: mcpContents,
4797
+ entry,
4798
+ normalizeText: true,
4799
+ plan: mcpRendered,
4800
+ reason: "mcp_render",
4801
+ sources: mcpSources,
4802
+ }),
4803
+ ...collectRenderedLedgerEvents({
4804
+ base: baseLedger,
4805
+ conflicts: pluginRendered.conflicts,
4806
+ contents: pluginPlan.contents,
4807
+ entry,
4808
+ normalizeText: false,
4809
+ plan: pluginRendered,
4810
+ reason: "plugin_render",
4811
+ sources: pluginPlan.sources,
4812
+ }),
4813
+ ];
4179
4814
 
4180
4815
  if (dryRun) {
4181
4816
  logSyncDryRun({
4182
4817
  tool,
4183
- entry,
4184
4818
  skillPlan,
4185
- mcpPlan,
4819
+ mcpPlan: {
4820
+ write: mcpRendered.write.length > 0,
4821
+ targetPath: entry.mcpConfig ?? "",
4822
+ },
4823
+ mcpConflicts: mcpRendered.conflicts,
4186
4824
  agentPlan: { add: agentRendered.write, remove: agentRendered.remove },
4187
4825
  agentConflicts: agentRendered.conflicts,
4188
4826
  automationPlan: {
@@ -4238,6 +4876,10 @@ async function syncManagedToolEntry({
4238
4876
  contents: configContents,
4239
4877
  targets: configRendered.write,
4240
4878
  });
4879
+ await applyRenderedWrites({
4880
+ contents: mcpContents,
4881
+ targets: mcpRendered.write,
4882
+ });
4241
4883
  await applyRenderedRemoves(pluginRendered.remove);
4242
4884
  await applyRenderedWrites({
4243
4885
  contents: pluginPlan.contents,
@@ -4251,7 +4893,10 @@ async function syncManagedToolEntry({
4251
4893
  logRenderedConflicts(tool, globalDocsRendered.conflicts);
4252
4894
  logRenderedConflicts(tool, rulesRendered.conflicts);
4253
4895
  logRenderedConflicts(tool, configRendered.conflicts);
4896
+ logRenderedConflicts(tool, mcpRendered.conflicts);
4254
4897
  logRenderedConflicts(tool, pluginRendered.conflicts);
4898
+ logSkillSymlinkConflicts(tool, skillPlan.adopted);
4899
+ logSkillSymlinkConflicts(tool, skillPlan.conflicts);
4255
4900
 
4256
4901
  updateRenderedTargetState({
4257
4902
  entry,
@@ -4288,6 +4933,13 @@ async function syncManagedToolEntry({
4288
4933
  contents: configContents,
4289
4934
  sources: configSources,
4290
4935
  });
4936
+ updateRenderedTargetState({
4937
+ entry,
4938
+ writtenTargets: mcpRendered.write,
4939
+ removedTargets: mcpRendered.remove,
4940
+ contents: mcpContents,
4941
+ sources: mcpSources,
4942
+ });
4291
4943
  updateRenderedTargetState({
4292
4944
  entry,
4293
4945
  writtenTargets: pluginRendered.write,
@@ -4302,6 +4954,7 @@ async function syncManagedToolEntry({
4302
4954
  `${tool}: adopted existing content ${name} into canonical store`
4303
4955
  );
4304
4956
  }
4957
+ await appendSyncLedgerEvents({ homeDir, rootDir, events: ledgerEvents });
4305
4958
  console.log(`${tool} synced`);
4306
4959
  }
4307
4960
  }