facult 2.3.1 → 2.5.0

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
@@ -32,7 +32,7 @@ import {
32
32
  type SkillEntry,
33
33
  } from "./index-builder";
34
34
  import {
35
- facultGeneratedStateDir,
35
+ facultMachineStateDir,
36
36
  facultRootDir,
37
37
  legacyFacultStateDirForRoot,
38
38
  projectRootFromAiRoot,
@@ -44,6 +44,7 @@ export interface ManagedToolState {
44
44
  skillsDir?: string;
45
45
  mcpConfig?: string;
46
46
  agentsDir?: string;
47
+ automationDir?: string;
47
48
  toolHome?: string;
48
49
  globalAgentsPath?: string;
49
50
  globalAgentsOverridePath?: string;
@@ -75,6 +76,7 @@ export interface ToolPaths {
75
76
  skillsDir?: string;
76
77
  mcpConfig?: string;
77
78
  agentsDir?: string;
79
+ automationDir?: string;
78
80
  toolHome?: string;
79
81
  rulesDir?: string;
80
82
  toolConfig?: string;
@@ -163,6 +165,7 @@ function defaultToolPaths(
163
165
  skillsDir: toolBase(".codex", "skills"),
164
166
  mcpConfig: toolBase(".codex", "mcp.json"),
165
167
  agentsDir: toolBase(".codex", "agents"),
168
+ automationDir: homePath(home, ".codex", "automations"),
166
169
  toolHome: toolBase(".codex"),
167
170
  rulesDir: toolBase(".codex", "rules"),
168
171
  toolConfig: toolBase(".codex", "config.toml"),
@@ -200,6 +203,19 @@ function defaultToolPaths(
200
203
  skillsDir: toolBase(".antigravity", "skills"),
201
204
  mcpConfig: toolBase(".antigravity", "mcp.json"),
202
205
  },
206
+ factory: {
207
+ tool: "factory",
208
+ skillsDir: projectRoot
209
+ ? join(projectRoot, ".factory", "skills")
210
+ : homePath(home, ".factory", "skills"),
211
+ mcpConfig: projectRoot
212
+ ? join(projectRoot, ".factory", "mcp.json")
213
+ : homePath(home, ".factory", "mcp.json"),
214
+ agentsDir: projectRoot
215
+ ? join(projectRoot, ".factory", "droids")
216
+ : homePath(home, ".factory", "droids"),
217
+ toolHome: projectRoot ? undefined : homePath(home, ".factory"),
218
+ },
203
219
  };
204
220
 
205
221
  const adapterDefaults = (tool: string): ToolPaths | null => {
@@ -293,7 +309,7 @@ export function managedStatePathForRoot(
293
309
  home: string = homedir(),
294
310
  rootDir?: string
295
311
  ): string {
296
- return join(facultGeneratedStateDir({ home, rootDir }), "managed.json");
312
+ return join(facultMachineStateDir(home, rootDir), "managed.json");
297
313
  }
298
314
 
299
315
  function legacyManagedStatePathForRoot(
@@ -336,7 +352,7 @@ export async function saveManagedState(
336
352
  home: string = homedir(),
337
353
  rootDir?: string
338
354
  ) {
339
- const dir = facultGeneratedStateDir({ home, rootDir });
355
+ const dir = facultMachineStateDir(home, rootDir);
340
356
  await ensureDir(dir);
341
357
  await Bun.write(
342
358
  managedStatePathForRoot(home, rootDir),
@@ -433,6 +449,167 @@ async function loadCanonicalAgents(
433
449
  return await loadAgentsFromRoot(homePath(rootDir, "agents"));
434
450
  }
435
451
 
452
+ function managedAgentFileExtension(tool: string): string {
453
+ return getAdapter(tool)?.agentFileExtension ?? ".toml";
454
+ }
455
+
456
+ async function renderManagedAgentFile(args: {
457
+ agent: { name: string; sourcePath: string; raw: string };
458
+ homeDir: string;
459
+ rootDir: string;
460
+ tool: string;
461
+ targetPath: string;
462
+ }): Promise<string> {
463
+ const adapter = getAdapter(args.tool);
464
+ if (adapter?.renderAgent) {
465
+ return await adapter.renderAgent({
466
+ raw: args.agent.raw,
467
+ homeDir: args.homeDir,
468
+ rootDir: args.rootDir,
469
+ projectRoot:
470
+ projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
471
+ tool: args.tool,
472
+ targetPath: args.targetPath,
473
+ });
474
+ }
475
+
476
+ return await renderCanonicalText(args.agent.raw, {
477
+ homeDir: args.homeDir,
478
+ rootDir: args.rootDir,
479
+ projectRoot: projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
480
+ targetTool: args.tool,
481
+ targetPath: args.targetPath,
482
+ });
483
+ }
484
+
485
+ async function loadManagedAgentsFromTool(args: {
486
+ tool: string;
487
+ agentsDir: string;
488
+ }): Promise<{ name: string; sourcePath: string; raw: string }[]> {
489
+ const adapter = getAdapter(args.tool);
490
+ if (!adapter?.parseManagedAgentFile) {
491
+ return await loadAgentsFromRoot(args.agentsDir);
492
+ }
493
+
494
+ const extension = managedAgentFileExtension(args.tool);
495
+ const entries = await readdir(args.agentsDir, { withFileTypes: true }).catch(
496
+ () => [] as import("node:fs").Dirent[]
497
+ );
498
+ const out: { name: string; sourcePath: string; raw: string }[] = [];
499
+
500
+ for (const entry of entries) {
501
+ if (!(entry.isFile() && entry.name.endsWith(extension))) {
502
+ continue;
503
+ }
504
+ const sourcePath = join(args.agentsDir, entry.name);
505
+ const parsed = await adapter.parseManagedAgentFile(sourcePath);
506
+ if (!parsed) {
507
+ continue;
508
+ }
509
+ out.push(parsed);
510
+ }
511
+
512
+ return out.sort((a, b) => a.name.localeCompare(b.name));
513
+ }
514
+
515
+ interface AutomationEntry {
516
+ name: string;
517
+ sourceDir: string;
518
+ files: Map<string, string>;
519
+ }
520
+
521
+ async function listRelativeFiles(root: string): Promise<string[]> {
522
+ const out: string[] = [];
523
+
524
+ async function visit(currentDir: string, prefix = ""): Promise<void> {
525
+ const entries = await readdir(currentDir, { withFileTypes: true }).catch(
526
+ () => [] as import("node:fs").Dirent[]
527
+ );
528
+ for (const entry of entries) {
529
+ if (entry.name.startsWith(".")) {
530
+ continue;
531
+ }
532
+ const relPath = prefix ? join(prefix, entry.name) : entry.name;
533
+ const fullPath = join(currentDir, entry.name);
534
+ if (entry.isDirectory()) {
535
+ await visit(fullPath, relPath);
536
+ continue;
537
+ }
538
+ if (entry.isFile()) {
539
+ out.push(relPath);
540
+ }
541
+ }
542
+ }
543
+
544
+ await visit(root);
545
+ return out.sort();
546
+ }
547
+
548
+ async function loadAutomationEntries(
549
+ automationsRoot: string
550
+ ): Promise<AutomationEntry[]> {
551
+ const entries = await readdir(automationsRoot, { withFileTypes: true }).catch(
552
+ () => [] as import("node:fs").Dirent[]
553
+ );
554
+ const out: AutomationEntry[] = [];
555
+
556
+ for (const entry of entries) {
557
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
558
+ continue;
559
+ }
560
+ const sourceDir = join(automationsRoot, entry.name);
561
+ const relativeFiles = await listRelativeFiles(sourceDir);
562
+ const files = new Map<string, string>();
563
+ for (const relPath of relativeFiles) {
564
+ const raw = await readTextIfExists(join(sourceDir, relPath));
565
+ if (raw == null) {
566
+ continue;
567
+ }
568
+ files.set(relPath, raw);
569
+ }
570
+ if (!files.has("automation.toml")) {
571
+ continue;
572
+ }
573
+ out.push({
574
+ name: entry.name,
575
+ sourceDir,
576
+ files,
577
+ });
578
+ }
579
+
580
+ return out.sort((a, b) => a.name.localeCompare(b.name));
581
+ }
582
+
583
+ async function loadCanonicalAutomations(
584
+ rootDir: string
585
+ ): Promise<AutomationEntry[]> {
586
+ return await loadAutomationEntries(join(rootDir, "automations"));
587
+ }
588
+
589
+ function automationEntriesEqual(
590
+ left: AutomationEntry,
591
+ right: AutomationEntry
592
+ ): boolean {
593
+ if (left.files.size !== right.files.size) {
594
+ return false;
595
+ }
596
+ for (const [relPath, leftRaw] of left.files.entries()) {
597
+ if (right.files.get(relPath) !== leftRaw) {
598
+ return false;
599
+ }
600
+ }
601
+ return true;
602
+ }
603
+
604
+ async function canonicalAutomationsExist(rootDir: string): Promise<boolean> {
605
+ try {
606
+ const automations = await loadCanonicalAutomations(rootDir);
607
+ return automations.length > 0;
608
+ } catch {
609
+ return false;
610
+ }
611
+ }
612
+
436
613
  async function loadMergedIndex(
437
614
  homeDir: string,
438
615
  rootDir: string
@@ -536,14 +713,15 @@ async function planAgentFileChanges({
536
713
  const contents = new Map<string, string>();
537
714
  const sources = new Map<string, string>();
538
715
  const desiredPaths = new Set<string>();
716
+ const extension = managedAgentFileExtension(tool);
539
717
 
540
718
  for (const agent of agents) {
541
- const target = homePath(agentsDir, `${agent.name}.toml`);
542
- const rendered = await renderCanonicalText(agent.raw, {
719
+ const target = homePath(agentsDir, `${agent.name}${extension}`);
720
+ const rendered = await renderManagedAgentFile({
721
+ agent,
543
722
  homeDir,
544
723
  rootDir,
545
- projectRoot: projectRootFromAiRoot(rootDir, homeDir) ?? undefined,
546
- targetTool: tool,
724
+ tool,
547
725
  targetPath: target,
548
726
  });
549
727
  desiredPaths.add(target);
@@ -558,7 +736,7 @@ async function planAgentFileChanges({
558
736
  const remove = new Set<string>();
559
737
 
560
738
  for (const entry of existing) {
561
- if (!(entry.isFile() && entry.name.endsWith(".toml"))) {
739
+ if (!(entry.isFile() && entry.name.endsWith(extension))) {
562
740
  continue;
563
741
  }
564
742
  const p = homePath(agentsDir, entry.name);
@@ -624,6 +802,58 @@ async function syncAgentFiles({
624
802
  return { add: plan.add, remove: plan.remove };
625
803
  }
626
804
 
805
+ async function planAutomationFileChanges(args: {
806
+ automationDir: string;
807
+ rootDir: string;
808
+ previouslyManagedTargets?: string[];
809
+ }): Promise<{
810
+ add: string[];
811
+ remove: string[];
812
+ contents: Map<string, string>;
813
+ sources: Map<string, string>;
814
+ }> {
815
+ const automations = await loadCanonicalAutomations(args.rootDir);
816
+ const contents = new Map<string, string>();
817
+ const sources = new Map<string, string>();
818
+ const desiredPaths = new Set<string>();
819
+
820
+ for (const automation of automations) {
821
+ for (const [relPath, raw] of automation.files.entries()) {
822
+ const targetPath = join(args.automationDir, automation.name, relPath);
823
+ const sourcePath = join(automation.sourceDir, relPath);
824
+ desiredPaths.add(targetPath);
825
+ contents.set(targetPath, raw);
826
+ sources.set(targetPath, sourcePath);
827
+ }
828
+ }
829
+
830
+ const add = new Set<string>();
831
+ for (const targetPath of desiredPaths) {
832
+ const current = await readTextIfExists(targetPath);
833
+ const desired = contents.get(targetPath);
834
+ if (desired != null && current !== desired) {
835
+ add.add(targetPath);
836
+ }
837
+ }
838
+
839
+ const remove = Array.from(
840
+ new Set(
841
+ (args.previouslyManagedTargets ?? []).filter(
842
+ (targetPath) =>
843
+ targetPath.startsWith(join(args.automationDir, "")) &&
844
+ !desiredPaths.has(targetPath)
845
+ )
846
+ )
847
+ ).sort();
848
+
849
+ return {
850
+ add: Array.from(add).sort(),
851
+ remove,
852
+ contents,
853
+ sources,
854
+ };
855
+ }
856
+
627
857
  async function listSkillDirs(skillsRoot: string): Promise<string[]> {
628
858
  try {
629
859
  const entries = await readdir(skillsRoot, { withFileTypes: true });
@@ -945,6 +1175,7 @@ interface ExistingManagedItem {
945
1175
  kind:
946
1176
  | "skill"
947
1177
  | "agent"
1178
+ | "automation"
948
1179
  | "global-doc"
949
1180
  | "rule"
950
1181
  | "tool-config"
@@ -1060,11 +1291,15 @@ function logManagedImportPlan(tool: string, plan: ExistingManagedImportPlan) {
1060
1291
  }
1061
1292
 
1062
1293
  async function planExistingToolAgentAdoption(args: {
1294
+ tool: string;
1063
1295
  rootDir: string;
1064
1296
  agentsDir: string;
1065
1297
  }): Promise<ExistingManagedImportPlan> {
1066
1298
  const plan = emptyManagedImportPlan();
1067
- const agents = await loadAgentsFromRoot(args.agentsDir);
1299
+ const agents = await loadManagedAgentsFromTool({
1300
+ tool: args.tool,
1301
+ agentsDir: args.agentsDir,
1302
+ });
1068
1303
  for (const agent of agents) {
1069
1304
  const canonicalPath = join(
1070
1305
  args.rootDir,
@@ -1091,12 +1326,16 @@ async function planExistingToolAgentAdoption(args: {
1091
1326
  }
1092
1327
 
1093
1328
  async function adoptExistingToolAgents(args: {
1329
+ tool: string;
1094
1330
  rootDir: string;
1095
1331
  agentsDir: string;
1096
1332
  conflictMode: "keep-canonical" | "keep-existing";
1097
1333
  }): Promise<ExistingManagedItem[]> {
1098
1334
  const adopted: ExistingManagedItem[] = [];
1099
- const agents = await loadAgentsFromRoot(args.agentsDir);
1335
+ const agents = await loadManagedAgentsFromTool({
1336
+ tool: args.tool,
1337
+ agentsDir: args.agentsDir,
1338
+ });
1100
1339
  for (const agent of agents) {
1101
1340
  const canonicalPath = join(
1102
1341
  args.rootDir,
@@ -1123,6 +1362,87 @@ async function adoptExistingToolAgents(args: {
1123
1362
  return adopted;
1124
1363
  }
1125
1364
 
1365
+ async function planExistingAutomationAdoption(args: {
1366
+ rootDir: string;
1367
+ automationDir: string;
1368
+ }): Promise<ExistingManagedImportPlan> {
1369
+ const plan = emptyManagedImportPlan();
1370
+ const liveAutomations = await loadAutomationEntries(args.automationDir);
1371
+ const canonicalAutomations = new Map(
1372
+ (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1373
+ entry.name,
1374
+ entry,
1375
+ ])
1376
+ );
1377
+
1378
+ for (const liveAutomation of liveAutomations) {
1379
+ const canonicalAutomation = canonicalAutomations.get(liveAutomation.name);
1380
+ if (!canonicalAutomation) {
1381
+ continue;
1382
+ }
1383
+ const item: ExistingManagedItem = {
1384
+ kind: "automation",
1385
+ name: liveAutomation.name,
1386
+ livePath: liveAutomation.sourceDir,
1387
+ canonicalPath: join(args.rootDir, "automations", liveAutomation.name),
1388
+ };
1389
+ if (automationEntriesEqual(liveAutomation, canonicalAutomation)) {
1390
+ plan.identical.push(item);
1391
+ } else {
1392
+ plan.conflicts.push(item);
1393
+ }
1394
+ }
1395
+
1396
+ return mergeManagedImportPlans(plan);
1397
+ }
1398
+
1399
+ async function adoptExistingAutomations(args: {
1400
+ rootDir: string;
1401
+ automationDir: string;
1402
+ conflictMode: "keep-canonical" | "keep-existing";
1403
+ }): Promise<ExistingManagedItem[]> {
1404
+ if (args.conflictMode !== "keep-existing") {
1405
+ return [];
1406
+ }
1407
+
1408
+ const adopted: ExistingManagedItem[] = [];
1409
+ const liveAutomations = await loadAutomationEntries(args.automationDir);
1410
+ const canonicalAutomations = new Map(
1411
+ (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1412
+ entry.name,
1413
+ entry,
1414
+ ])
1415
+ );
1416
+
1417
+ for (const liveAutomation of liveAutomations) {
1418
+ const canonicalAutomation = canonicalAutomations.get(liveAutomation.name);
1419
+ if (
1420
+ !(
1421
+ canonicalAutomation &&
1422
+ !automationEntriesEqual(liveAutomation, canonicalAutomation)
1423
+ )
1424
+ ) {
1425
+ continue;
1426
+ }
1427
+ const canonicalPath = join(
1428
+ args.rootDir,
1429
+ "automations",
1430
+ liveAutomation.name
1431
+ );
1432
+ await ensureDir(dirname(canonicalPath));
1433
+ await rm(canonicalPath, { recursive: true, force: true });
1434
+ await cp(liveAutomation.sourceDir, canonicalPath, { recursive: true });
1435
+ adopted.push({
1436
+ kind: "automation",
1437
+ name: liveAutomation.name,
1438
+ livePath: liveAutomation.sourceDir,
1439
+ canonicalPath,
1440
+ });
1441
+ }
1442
+
1443
+ return adopted;
1444
+ }
1445
+
1126
1446
  async function planExistingGlobalDocAdoption(args: {
1127
1447
  rootDir: string;
1128
1448
  tool: string;
@@ -1784,10 +2104,17 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1784
2104
  asManagedSkillPlan(existingSkillPlan),
1785
2105
  toolPaths.agentsDir
1786
2106
  ? await planExistingToolAgentAdoption({
2107
+ tool,
1787
2108
  rootDir,
1788
2109
  agentsDir: toolPaths.agentsDir,
1789
2110
  })
1790
2111
  : emptyManagedImportPlan(),
2112
+ toolPaths.automationDir
2113
+ ? await planExistingAutomationAdoption({
2114
+ rootDir,
2115
+ automationDir: toolPaths.automationDir,
2116
+ })
2117
+ : emptyManagedImportPlan(),
1791
2118
  toolPaths.toolHome
1792
2119
  ? await planExistingGlobalDocAdoption({
1793
2120
  rootDir,
@@ -1826,6 +2153,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1826
2153
  if (
1827
2154
  (toolPaths.skillsDir ||
1828
2155
  toolPaths.agentsDir ||
2156
+ toolPaths.automationDir ||
1829
2157
  toolPaths.toolHome ||
1830
2158
  toolPaths.rulesDir ||
1831
2159
  toolPaths.toolConfig ||
@@ -1900,12 +2228,21 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1900
2228
  }
1901
2229
  if (toolPaths.agentsDir && opts.adoptExisting) {
1902
2230
  const result = await adoptExistingToolAgents({
2231
+ tool,
1903
2232
  rootDir,
1904
2233
  agentsDir: toolPaths.agentsDir,
1905
2234
  conflictMode: importConflictMode,
1906
2235
  });
1907
2236
  adoptedSkills.push(...result.map((item) => item.name));
1908
2237
  }
2238
+ if (toolPaths.automationDir && opts.adoptExisting) {
2239
+ const result = await adoptExistingAutomations({
2240
+ rootDir,
2241
+ automationDir: toolPaths.automationDir,
2242
+ conflictMode: importConflictMode,
2243
+ });
2244
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
2245
+ }
1909
2246
  if (toolPaths.toolHome && opts.adoptExisting) {
1910
2247
  const result = await adoptExistingGlobalDocs({
1911
2248
  rootDir,
@@ -1957,6 +2294,12 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1957
2294
  tool,
1958
2295
  })
1959
2296
  : null;
2297
+ const automationPreview = toolPaths.automationDir
2298
+ ? await planAutomationFileChanges({
2299
+ automationDir: toolPaths.automationDir,
2300
+ rootDir,
2301
+ })
2302
+ : null;
1960
2303
  const globalDocsPreview = toolPaths.toolHome
1961
2304
  ? await planToolGlobalDocsSync({
1962
2305
  homeDir: home,
@@ -2048,6 +2391,16 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2048
2391
  });
2049
2392
  }
2050
2393
 
2394
+ if (toolPaths.automationDir && automationPreview) {
2395
+ await ensureDir(toolPaths.automationDir);
2396
+ await applyRenderedRemoves(automationPreview.remove);
2397
+ await applyRenderedWrites({
2398
+ contents: automationPreview.contents,
2399
+ targets: Array.from(automationPreview.contents.keys()),
2400
+ });
2401
+ await pruneEmptyParents(automationPreview.remove, toolPaths.automationDir);
2402
+ }
2403
+
2051
2404
  if (toolPaths.toolHome && globalDocsPreview) {
2052
2405
  await ensureDir(toolPaths.toolHome);
2053
2406
  await applyRenderedRemoves(globalDocsPreview.remove);
@@ -2086,6 +2439,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2086
2439
  skillsDir: toolPaths.skillsDir,
2087
2440
  mcpConfig: toolPaths.mcpConfig,
2088
2441
  agentsDir: toolPaths.agentsDir,
2442
+ automationDir: toolPaths.automationDir,
2089
2443
  toolHome: globalDocsPreview?.managedTargets.length
2090
2444
  ? toolPaths.toolHome
2091
2445
  : undefined,
@@ -2123,6 +2477,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2123
2477
  sources: agentPreview.sources,
2124
2478
  });
2125
2479
  }
2480
+ if (automationPreview) {
2481
+ updateRenderedTargetState({
2482
+ entry: managedEntry,
2483
+ writtenTargets: Array.from(automationPreview.contents.keys()),
2484
+ removedTargets: automationPreview.remove,
2485
+ contents: automationPreview.contents,
2486
+ sources: automationPreview.sources,
2487
+ });
2488
+ }
2126
2489
  if (globalDocsPreview) {
2127
2490
  updateRenderedTargetState({
2128
2491
  entry: managedEntry,
@@ -2238,6 +2601,14 @@ export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
2238
2601
  });
2239
2602
  }
2240
2603
 
2604
+ if (entry.automationDir) {
2605
+ const automationTargets = Object.keys(entry.renderedTargets ?? {}).filter(
2606
+ (targetPath) => targetPath.startsWith(join(entry.automationDir!, ""))
2607
+ );
2608
+ await applyRenderedRemoves(automationTargets);
2609
+ await pruneEmptyParents(automationTargets, entry.automationDir);
2610
+ }
2611
+
2241
2612
  if (entry.globalAgentsPath) {
2242
2613
  await restoreBackup({
2243
2614
  original: entry.globalAgentsPath,
@@ -2320,6 +2691,15 @@ async function repairManagedToolEntry(args: {
2320
2691
  changed = true;
2321
2692
  }
2322
2693
 
2694
+ if (
2695
+ !next.automationDir &&
2696
+ toolPaths.automationDir &&
2697
+ (await canonicalAutomationsExist(rootDir))
2698
+ ) {
2699
+ next.automationDir = toolPaths.automationDir;
2700
+ changed = true;
2701
+ }
2702
+
2323
2703
  if (toolPaths.toolHome && !next.toolHome) {
2324
2704
  const preview = await syncToolGlobalDocs({
2325
2705
  homeDir,
@@ -2406,6 +2786,7 @@ async function planRenderedTargetConflicts(args: {
2406
2786
  desiredContents: Map<string, string>;
2407
2787
  desiredSources: Map<string, string>;
2408
2788
  conflictMode?: "warn" | "overwrite";
2789
+ protectAllSources?: boolean;
2409
2790
  }): Promise<RenderedApplyPlan> {
2410
2791
  if (args.conflictMode === "overwrite") {
2411
2792
  return {
@@ -2433,7 +2814,7 @@ async function planRenderedTargetConflicts(args: {
2433
2814
  continue;
2434
2815
  }
2435
2816
  const sourceKind = renderedSourceKindForPath(sourcePath);
2436
- if (sourceKind !== "builtin") {
2817
+ if (sourceKind !== "builtin" && !args.protectAllSources) {
2437
2818
  if (args.desiredWrites.includes(targetPath)) {
2438
2819
  write.push(targetPath);
2439
2820
  } else {
@@ -2452,8 +2833,16 @@ async function planRenderedTargetConflicts(args: {
2452
2833
  }
2453
2834
 
2454
2835
  const currentHash = renderedHash(current);
2836
+ const desiredHash = args.desiredContents.get(targetPath)
2837
+ ? renderedHash(args.desiredContents.get(targetPath)!)
2838
+ : null;
2455
2839
  if (prior?.hash) {
2456
- if (currentHash === prior.hash) {
2840
+ if (
2841
+ currentHash === prior.hash ||
2842
+ (args.desiredWrites.includes(targetPath) &&
2843
+ desiredHash != null &&
2844
+ currentHash === desiredHash)
2845
+ ) {
2457
2846
  if (args.desiredWrites.includes(targetPath)) {
2458
2847
  write.push(targetPath);
2459
2848
  } else {
@@ -2470,6 +2859,15 @@ async function planRenderedTargetConflicts(args: {
2470
2859
  continue;
2471
2860
  }
2472
2861
 
2862
+ if (
2863
+ args.desiredWrites.includes(targetPath) &&
2864
+ desiredHash != null &&
2865
+ currentHash === desiredHash
2866
+ ) {
2867
+ write.push(targetPath);
2868
+ continue;
2869
+ }
2870
+
2473
2871
  conflicts.push({
2474
2872
  targetPath,
2475
2873
  sourcePath,
@@ -2496,8 +2894,14 @@ function logRenderedConflicts(
2496
2894
  conflict.reason === "unknown_state"
2497
2895
  ? "no prior managed hash is recorded"
2498
2896
  : "local edits were detected";
2897
+ const surface =
2898
+ conflict.sourceKind === "builtin"
2899
+ ? "builtin-backed target"
2900
+ : "managed target";
2499
2901
  console.warn(
2500
- `${tool}: ${verb} builtin-backed target ${conflict.targetPath} because ${state}. Rerun with "--builtin-conflicts overwrite" to replace it with the latest packaged default.`
2902
+ conflict.sourceKind === "builtin"
2903
+ ? `${tool}: ${verb} ${surface} ${conflict.targetPath} because ${state}. Rerun with "--builtin-conflicts overwrite" to replace it with the latest packaged default.`
2904
+ : `${tool}: ${verb} ${surface} ${conflict.targetPath} because ${state}.`
2501
2905
  );
2502
2906
  }
2503
2907
  }
@@ -2525,6 +2929,24 @@ async function applyRenderedRemoves(targets: string[]) {
2525
2929
  }
2526
2930
  }
2527
2931
 
2932
+ async function pruneEmptyParents(targets: string[], stopDir: string) {
2933
+ const candidateDirs = Array.from(
2934
+ new Set(targets.map((pathValue) => dirname(pathValue)))
2935
+ ).sort((a, b) => b.length - a.length);
2936
+
2937
+ for (const startDir of candidateDirs) {
2938
+ let currentDir = startDir;
2939
+ while (currentDir.startsWith(join(stopDir, "")) && currentDir !== stopDir) {
2940
+ const entries = await readdir(currentDir).catch(() => null);
2941
+ if (!(entries && entries.length === 0)) {
2942
+ break;
2943
+ }
2944
+ await rm(currentDir, { recursive: true, force: true });
2945
+ currentDir = dirname(currentDir);
2946
+ }
2947
+ }
2948
+ }
2949
+
2528
2950
  function updateRenderedTargetState(args: {
2529
2951
  entry: ManagedToolState;
2530
2952
  writtenTargets: string[];
@@ -2558,6 +2980,8 @@ function logSyncDryRun({
2558
2980
  mcpPlan,
2559
2981
  agentPlan,
2560
2982
  agentConflicts,
2983
+ automationPlan,
2984
+ automationConflicts,
2561
2985
  globalDocsPlan,
2562
2986
  globalDocsConflicts,
2563
2987
  rulesPlan,
@@ -2571,6 +2995,8 @@ function logSyncDryRun({
2571
2995
  mcpPlan: { needsWrite: boolean };
2572
2996
  agentPlan: { add: string[]; remove: string[] };
2573
2997
  agentConflicts: RenderedConflict[];
2998
+ automationPlan: { write: string[]; remove: string[] };
2999
+ automationConflicts: RenderedConflict[];
2574
3000
  globalDocsPlan: { write: string[]; remove: string[] };
2575
3001
  globalDocsConflicts: RenderedConflict[];
2576
3002
  rulesPlan: { write: string[]; remove: string[] };
@@ -2591,6 +3017,13 @@ function logSyncDryRun({
2591
3017
  console.log(`${tool}: would remove agent ${p}`);
2592
3018
  }
2593
3019
  logRenderedConflicts(tool, agentConflicts, true);
3020
+ for (const p of automationPlan.write) {
3021
+ console.log(`${tool}: would write automation ${p}`);
3022
+ }
3023
+ for (const p of automationPlan.remove) {
3024
+ console.log(`${tool}: would remove automation ${p}`);
3025
+ }
3026
+ logRenderedConflicts(tool, automationConflicts, true);
2594
3027
  for (const p of globalDocsPlan.write) {
2595
3028
  console.log(`${tool}: would write global doc ${p}`);
2596
3029
  }
@@ -2620,6 +3053,8 @@ function logSyncDryRun({
2620
3053
  skillPlan.remove.length === 0 &&
2621
3054
  agentPlan.add.length === 0 &&
2622
3055
  agentPlan.remove.length === 0 &&
3056
+ automationPlan.write.length === 0 &&
3057
+ automationPlan.remove.length === 0 &&
2623
3058
  globalDocsPlan.write.length === 0 &&
2624
3059
  globalDocsPlan.remove.length === 0 &&
2625
3060
  rulesPlan.write.length === 0 &&
@@ -2628,6 +3063,7 @@ function logSyncDryRun({
2628
3063
  !configPlan.remove &&
2629
3064
  !mcpPlan.needsWrite &&
2630
3065
  agentConflicts.length === 0 &&
3066
+ automationConflicts.length === 0 &&
2631
3067
  globalDocsConflicts.length === 0 &&
2632
3068
  rulesConflicts.length === 0 &&
2633
3069
  configConflicts.length === 0
@@ -2657,6 +3093,7 @@ async function repairManagedCanonicalContent(args: {
2657
3093
 
2658
3094
  if (args.entry.agentsBackup) {
2659
3095
  const items = await adoptExistingToolAgents({
3096
+ tool: args.entry.tool,
2660
3097
  rootDir: args.rootDir,
2661
3098
  agentsDir: args.entry.agentsBackup,
2662
3099
  conflictMode: "keep-canonical",
@@ -2767,6 +3204,13 @@ async function syncManagedToolEntry({
2767
3204
  tool,
2768
3205
  })
2769
3206
  : { add: [], remove: [], contents: new Map(), sources: new Map() };
3207
+ const automationPlan = entry.automationDir
3208
+ ? await planAutomationFileChanges({
3209
+ automationDir: entry.automationDir,
3210
+ rootDir,
3211
+ previouslyManagedTargets: Object.keys(entry.renderedTargets ?? {}),
3212
+ })
3213
+ : { add: [], remove: [], contents: new Map(), sources: new Map() };
2770
3214
 
2771
3215
  const mcpPlan = entry.mcpConfig
2772
3216
  ? await syncMcpConfig({
@@ -2846,6 +3290,15 @@ async function syncManagedToolEntry({
2846
3290
  desiredSources: globalDocsPlan.sources,
2847
3291
  conflictMode: builtinConflictMode,
2848
3292
  });
3293
+ const automationRendered = await planRenderedTargetConflicts({
3294
+ entry,
3295
+ desiredWrites: automationPlan.add,
3296
+ desiredRemoves: automationPlan.remove,
3297
+ desiredContents: automationPlan.contents,
3298
+ desiredSources: automationPlan.sources,
3299
+ conflictMode: builtinConflictMode,
3300
+ protectAllSources: true,
3301
+ });
2849
3302
  const rulesRendered = await planRenderedTargetConflicts({
2850
3303
  entry,
2851
3304
  desiredWrites: rulesPlan.write,
@@ -2882,6 +3335,11 @@ async function syncManagedToolEntry({
2882
3335
  mcpPlan,
2883
3336
  agentPlan: { add: agentRendered.write, remove: agentRendered.remove },
2884
3337
  agentConflicts: agentRendered.conflicts,
3338
+ automationPlan: {
3339
+ write: automationRendered.write,
3340
+ remove: automationRendered.remove,
3341
+ },
3342
+ automationConflicts: automationRendered.conflicts,
2885
3343
  globalDocsPlan: {
2886
3344
  write: globalDocsRendered.write,
2887
3345
  remove: globalDocsRendered.remove,
@@ -2902,6 +3360,14 @@ async function syncManagedToolEntry({
2902
3360
  contents: agentPlan.contents,
2903
3361
  targets: agentRendered.write,
2904
3362
  });
3363
+ await applyRenderedRemoves(automationRendered.remove);
3364
+ await applyRenderedWrites({
3365
+ contents: automationPlan.contents,
3366
+ targets: automationRendered.write,
3367
+ });
3368
+ if (entry.automationDir) {
3369
+ await pruneEmptyParents(automationRendered.remove, entry.automationDir);
3370
+ }
2905
3371
  await applyRenderedRemoves(globalDocsRendered.remove);
2906
3372
  await applyRenderedWrites({
2907
3373
  contents: globalDocsPlan.contents,
@@ -2918,6 +3384,7 @@ async function syncManagedToolEntry({
2918
3384
  targets: configRendered.write,
2919
3385
  });
2920
3386
  logRenderedConflicts(tool, agentRendered.conflicts);
3387
+ logRenderedConflicts(tool, automationRendered.conflicts);
2921
3388
  logRenderedConflicts(tool, globalDocsRendered.conflicts);
2922
3389
  logRenderedConflicts(tool, rulesRendered.conflicts);
2923
3390
  logRenderedConflicts(tool, configRendered.conflicts);
@@ -2929,6 +3396,13 @@ async function syncManagedToolEntry({
2929
3396
  contents: agentPlan.contents,
2930
3397
  sources: agentPlan.sources,
2931
3398
  });
3399
+ updateRenderedTargetState({
3400
+ entry,
3401
+ writtenTargets: automationRendered.write,
3402
+ removedTargets: automationRendered.remove,
3403
+ contents: automationPlan.contents,
3404
+ sources: automationPlan.sources,
3405
+ });
2932
3406
  updateRenderedTargetState({
2933
3407
  entry,
2934
3408
  writtenTargets: globalDocsRendered.write,