facult 2.3.1 → 2.4.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/README.md CHANGED
@@ -260,9 +260,17 @@ fclt index
260
260
 
261
261
  Why `keep-current`: it is deterministic and non-interactive for duplicate sources.
262
262
 
263
- Canonical source root: `~/.ai` for global work, or `<repo>/.ai` for project-local work. Facult-owned generated/config/runtime state lives inside the active canonical root:
264
- - global: `~/.ai/.facult`
265
- - project: `<repo>/.ai/.facult`
263
+ Canonical source root: `~/.ai` for global work, or `<repo>/.ai` for project-local work.
264
+
265
+ Generated AI state that belongs with the canonical root lives inside that root:
266
+ - global: `~/.ai/.facult/ai/...`
267
+ - project: `<repo>/.ai/.facult/ai/...`
268
+
269
+ Machine-local operational state lives outside the canonical root:
270
+ - macOS state: `~/Library/Application Support/fclt/...`
271
+ - macOS cache: `~/Library/Caches/fclt/...`
272
+ - Linux/other state: `${XDG_STATE_HOME:-~/.local/state}/fclt/...`
273
+ - Linux/other cache: `${XDG_CACHE_HOME:-~/.cache}/fclt/...`
266
274
 
267
275
  ### 3b. Bootstrap a repo-local `.ai`
268
276
 
@@ -420,7 +428,8 @@ Typical layout:
420
428
 
421
429
  Important split:
422
430
  - `.ai/` is canonical source
423
- - `.ai/.facult/` is Facult-owned generated state, trust state, managed tool state, autosync state, and caches
431
+ - `.ai/.facult/ai/` is generated AI state that belongs with the canonical root
432
+ - machine-local Facult state such as managed-tool state, autosync runtime/config, install metadata, and launcher caches lives outside `.ai/`
424
433
  - tool homes such as `.codex/` and `.claude/` are rendered outputs
425
434
  - the generated capability graph lives at `.ai/.facult/ai/graph.json`
426
435
 
@@ -714,6 +723,13 @@ Files are written to:
714
723
  - `~/.codex/automations/<name>/automation.toml`
715
724
  - `~/.codex/automations/<name>/memory.md`
716
725
 
726
+ When Codex is in managed mode, canonical automation sources live under:
727
+
728
+ - `~/.ai/automations/<name>/...` for global automation state
729
+ - `<repo>/.ai/automations/<name>/...` for project-scoped canonical state
730
+
731
+ Managed sync renders those canonical automation directories into the shared live Codex automation store at `~/.codex/automations/` and only removes automation files that were previously rendered by the same canonical root.
732
+
717
733
  Example project automation:
718
734
 
719
735
  ```bash
@@ -774,17 +790,21 @@ fclt <command> --help
774
790
 
775
791
  ### State and report files
776
792
 
777
- Under `~/.ai/.facult/`:
793
+ Under canonical generated AI state (`~/.ai/.facult/` or `<repo>/.ai/.facult/`):
778
794
  - `sources.json` (latest inventory scan state)
779
795
  - `consolidated.json` (consolidation state)
780
- - `managed.json` (managed tool state)
781
796
  - `ai/index.json` (generated canonical AI inventory)
782
797
  - `audit/static-latest.json` (latest static audit report)
783
798
  - `audit/agent-latest.json` (latest agent audit report)
784
799
  - `trust/sources.json` (source trust policy state)
785
- - `autosync/services/*.json` (autosync service configs)
786
- - `autosync/state/*.json` (autosync runtime state)
787
- - `autosync/logs/*` (autosync service logs)
800
+
801
+ Under machine-local Facult state:
802
+ - `install.json` (machine-local install metadata)
803
+ - `global/managed.json` or `projects/<slug-hash>/managed.json` (managed tool state)
804
+ - `.../autosync/services/*.json` (autosync service configs)
805
+ - `.../autosync/state/*.json` (autosync runtime state)
806
+ - `.../autosync/logs/*` (autosync service logs)
807
+ - `runtime/<version>/<platform-arch>/...` under the machine-local cache root (npm launcher binary cache)
788
808
 
789
809
  ### Config reference
790
810
 
@@ -890,7 +910,7 @@ Release behavior:
890
910
  4. npm publish runs only after binary asset upload succeeds (`publish-npm` depends on `publish-assets`).
891
911
  5. Published release assets include platform binaries, `fclt-install.sh`, `facult-install.sh`, and `SHA256SUMS`.
892
912
  6. When `HOMEBREW_TAP_TOKEN` is configured, the release workflow also updates the Homebrew tap at `hack-dance/homebrew-tap`.
893
- 7. The npm package launcher resolves your platform, downloads the matching release binary, caches it under `~/.ai/.facult/runtime/<version>/<platform-arch>/`, and runs it.
913
+ 7. The npm package launcher resolves your platform, downloads the matching release binary, caches it under the machine-local cache root (`~/Library/Caches/fclt/runtime/...` on macOS or `${XDG_CACHE_HOME:-~/.cache}/fclt/runtime/...` elsewhere), and runs it.
894
914
 
895
915
  Current prebuilt binary targets:
896
916
  - `darwin-x64`
package/bin/fclt.cjs CHANGED
@@ -16,6 +16,43 @@ const PACKAGE_NAME = "facult";
16
16
  const DOWNLOAD_RETRIES = 12;
17
17
  const DOWNLOAD_RETRY_DELAY_MS = 5000;
18
18
 
19
+ function isHelpLikeArgs(args) {
20
+ return (
21
+ args.length === 0 ||
22
+ args.includes("--help") ||
23
+ args.includes("-h") ||
24
+ args[0] === "help"
25
+ );
26
+ }
27
+
28
+ function localStateRoot(home) {
29
+ const override = String(process.env.FACULT_LOCAL_STATE_DIR || "").trim();
30
+ if (override) {
31
+ return path.resolve(override);
32
+ }
33
+ if (process.platform === "darwin") {
34
+ return path.join(home, "Library", "Application Support", "fclt");
35
+ }
36
+ const xdg = String(process.env.XDG_STATE_HOME || "").trim();
37
+ return xdg
38
+ ? path.join(path.resolve(xdg), "fclt")
39
+ : path.join(home, ".local", "state", "fclt");
40
+ }
41
+
42
+ function localCacheRoot(home) {
43
+ const override = String(process.env.FACULT_CACHE_DIR || "").trim();
44
+ if (override) {
45
+ return path.resolve(override);
46
+ }
47
+ if (process.platform === "darwin") {
48
+ return path.join(home, "Library", "Caches", "fclt");
49
+ }
50
+ const xdg = String(process.env.XDG_CACHE_HOME || "").trim();
51
+ return xdg
52
+ ? path.join(path.resolve(xdg), "fclt")
53
+ : path.join(home, ".cache", "fclt");
54
+ }
55
+
19
56
  async function main() {
20
57
  const resolved = resolveTarget();
21
58
  if (!resolved.ok) {
@@ -30,7 +67,7 @@ async function main() {
30
67
  }
31
68
 
32
69
  const home = os.homedir();
33
- const cacheRoot = path.join(home, ".ai", ".facult", "runtime");
70
+ const cacheRoot = path.join(localCacheRoot(home), "runtime");
34
71
  const installDir = path.join(
35
72
  cacheRoot,
36
73
  version,
@@ -39,9 +76,34 @@ async function main() {
39
76
  const binaryName = resolved.platform === "windows" ? "fclt.exe" : "fclt";
40
77
  const binaryPath = path.join(installDir, binaryName);
41
78
  const sourceEntry = path.join(__dirname, "..", "src", "index.ts");
79
+ const args = process.argv.slice(2);
42
80
  let installedBinaryThisRun = false;
43
81
 
44
82
  if (!(await fileExists(binaryPath))) {
83
+ const packageManager = detectPackageManager();
84
+ const hasSourceFallback = await canUseSourceFallback(sourceEntry);
85
+ const incompleteCache = await hasIncompleteRuntimeCache({
86
+ installDir,
87
+ binaryName,
88
+ });
89
+
90
+ if (incompleteCache) {
91
+ await removeIncompleteRuntimeTemps({ installDir, binaryName });
92
+ }
93
+
94
+ if (hasSourceFallback && (incompleteCache || isHelpLikeArgs(args))) {
95
+ return runSourceFallback({
96
+ sourceEntry,
97
+ version,
98
+ packageManager,
99
+ reason: new Error(
100
+ incompleteCache
101
+ ? "incomplete cached runtime download"
102
+ : "runtime binary missing for help-like command"
103
+ ),
104
+ });
105
+ }
106
+
45
107
  const tag = `v${version}`;
46
108
  const assetName = `${PACKAGE_NAME}-${version}-${resolved.platform}-${resolved.arch}${resolved.ext}`;
47
109
  const url = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${tag}/${assetName}`;
@@ -95,7 +157,6 @@ async function main() {
95
157
  });
96
158
  }
97
159
 
98
- const args = process.argv.slice(2);
99
160
  const result = spawnSync(binaryPath, args, {
100
161
  stdio: "inherit",
101
162
  env: {
@@ -281,6 +342,28 @@ async function fileExists(filePath) {
281
342
  }
282
343
  }
283
344
 
345
+ async function hasIncompleteRuntimeCache({ installDir, binaryName }) {
346
+ try {
347
+ const entries = await fsp.readdir(installDir);
348
+ return entries.some((entry) => entry.startsWith(`${binaryName}.tmp-`));
349
+ } catch {
350
+ return false;
351
+ }
352
+ }
353
+
354
+ async function removeIncompleteRuntimeTemps({ installDir, binaryName }) {
355
+ try {
356
+ const entries = await fsp.readdir(installDir);
357
+ await Promise.all(
358
+ entries
359
+ .filter((entry) => entry.startsWith(`${binaryName}.tmp-`))
360
+ .map((entry) => safeUnlink(path.join(installDir, entry)))
361
+ );
362
+ } catch {
363
+ // Ignore missing runtime dirs while cleaning stale temp files.
364
+ }
365
+ }
366
+
284
367
  async function safeUnlink(filePath) {
285
368
  try {
286
369
  await fsp.unlink(filePath);
@@ -295,7 +378,7 @@ function sleep(ms) {
295
378
 
296
379
  async function writeInstallState(state) {
297
380
  const home = os.homedir();
298
- const installStateDir = path.join(home, ".ai", ".facult");
381
+ const installStateDir = localStateRoot(home);
299
382
  const installStatePath = path.join(installStateDir, "install.json");
300
383
  await fsp.mkdir(installStateDir, { recursive: true });
301
384
  await fsp.writeFile(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/autosync.ts CHANGED
@@ -5,6 +5,7 @@ import { basename, dirname, join } from "node:path";
5
5
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
6
6
  import { syncManagedTools } from "./manage";
7
7
  import {
8
+ facultMachineStateDir,
8
9
  facultRootDir,
9
10
  facultStateDir,
10
11
  legacyFacultStateDirForRoot,
@@ -84,6 +85,10 @@ interface GitSyncOutcome {
84
85
  message?: string;
85
86
  }
86
87
 
88
+ let launchctlRunnerForTests:
89
+ | ((args: string[]) => Promise<CommandResult>)
90
+ | null = null;
91
+
87
92
  function nowIso(): string {
88
93
  return new Date().toISOString();
89
94
  }
@@ -100,6 +105,10 @@ function runDetached(context: string, promise: Promise<void>) {
100
105
  }
101
106
 
102
107
  function autosyncDir(home: string, rootDir?: string): string {
108
+ return join(facultMachineStateDir(home, rootDir), "autosync");
109
+ }
110
+
111
+ function canonicalAutosyncDir(home: string, rootDir?: string): string {
103
112
  return join(facultStateDir(home, rootDir), "autosync");
104
113
  }
105
114
 
@@ -155,11 +164,21 @@ function autosyncServiceName(
155
164
  }
156
165
 
157
166
  function autosyncLabel(serviceName: string): string {
167
+ return serviceName === "all"
168
+ ? "com.fclt.autosync"
169
+ : `com.fclt.autosync.${serviceName}`;
170
+ }
171
+
172
+ function legacyAutosyncLabel(serviceName: string): string {
158
173
  return serviceName === "all"
159
174
  ? "com.facult.autosync"
160
175
  : `com.facult.autosync.${serviceName}`;
161
176
  }
162
177
 
178
+ function autosyncLabelCandidates(serviceName: string): string[] {
179
+ return [autosyncLabel(serviceName), legacyAutosyncLabel(serviceName)];
180
+ }
181
+
163
182
  function autosyncPlistPath(home: string, serviceName: string): string {
164
183
  return join(
165
184
  home,
@@ -331,6 +350,11 @@ export async function loadAutosyncConfig(
331
350
  ): Promise<AutosyncServiceConfig | null> {
332
351
  const candidates = [
333
352
  autosyncConfigPath(homeDir, serviceName, rootDir),
353
+ join(
354
+ canonicalAutosyncDir(homeDir, rootDir),
355
+ "services",
356
+ `${serviceName}.json`
357
+ ),
334
358
  legacyAutosyncConfigPath(homeDir, serviceName, rootDir),
335
359
  ];
336
360
  for (const candidate of candidates) {
@@ -359,6 +383,11 @@ export async function loadAutosyncRuntimeState(
359
383
  ): Promise<AutosyncRuntimeState | null> {
360
384
  const candidates = [
361
385
  autosyncRuntimeStatePath(homeDir, serviceName, rootDir),
386
+ join(
387
+ canonicalAutosyncDir(homeDir, rootDir),
388
+ "state",
389
+ `${serviceName}.json`
390
+ ),
362
391
  legacyAutosyncRuntimeStatePath(homeDir, serviceName, rootDir),
363
392
  ];
364
393
  for (const candidate of candidates) {
@@ -399,9 +428,18 @@ async function runCommand(
399
428
  }
400
429
 
401
430
  async function runLaunchctl(args: string[]): Promise<CommandResult> {
431
+ if (launchctlRunnerForTests) {
432
+ return await launchctlRunnerForTests(args);
433
+ }
402
434
  return await runCommand(["launchctl", ...args]);
403
435
  }
404
436
 
437
+ export function setLaunchctlRunnerForTests(
438
+ runner: ((args: string[]) => Promise<CommandResult>) | null
439
+ ) {
440
+ launchctlRunnerForTests = runner;
441
+ }
442
+
405
443
  function launchdDomain(): string {
406
444
  return `gui/${process.getuid?.() ?? process.geteuid?.() ?? 0}`;
407
445
  }
@@ -470,6 +508,122 @@ async function ensureGitRepo(repoDir: string): Promise<boolean> {
470
508
  return await pathExists(join(repoDir, ".git"));
471
509
  }
472
510
 
511
+ async function cleanupAutosyncLaunchAgentArtifacts(args: {
512
+ homeDir: string;
513
+ serviceName: string;
514
+ }) {
515
+ const domain = launchdDomain();
516
+ for (const label of autosyncLabelCandidates(args.serviceName)) {
517
+ await runLaunchctl(["bootout", `${domain}/${label}`]).catch(() => null);
518
+ }
519
+
520
+ const legacyPlistPath = join(
521
+ args.homeDir,
522
+ "Library",
523
+ "LaunchAgents",
524
+ `${legacyAutosyncLabel(args.serviceName)}.plist`
525
+ );
526
+ if (legacyPlistPath !== autosyncPlistPath(args.homeDir, args.serviceName)) {
527
+ await rm(legacyPlistPath, { force: true });
528
+ }
529
+ }
530
+
531
+ async function cleanupLegacyAutosyncFiles(args: {
532
+ homeDir: string;
533
+ serviceName: string;
534
+ rootDir: string;
535
+ }) {
536
+ const legacyPaths = [
537
+ join(
538
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
539
+ "services",
540
+ `${args.serviceName}.json`
541
+ ),
542
+ join(
543
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
544
+ "state",
545
+ `${args.serviceName}.json`
546
+ ),
547
+ join(
548
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
549
+ "logs",
550
+ `${args.serviceName}.log`
551
+ ),
552
+ join(
553
+ canonicalAutosyncDir(args.homeDir, args.rootDir),
554
+ "logs",
555
+ `${args.serviceName}.err.log`
556
+ ),
557
+ legacyAutosyncConfigPath(args.homeDir, args.serviceName, args.rootDir),
558
+ legacyAutosyncRuntimeStatePath(
559
+ args.homeDir,
560
+ args.serviceName,
561
+ args.rootDir
562
+ ),
563
+ ];
564
+ for (const candidate of legacyPaths) {
565
+ await rm(candidate, { force: true }).catch(() => null);
566
+ }
567
+ }
568
+
569
+ const AUTOSYNC_REBUILDABLE_PATHS = [
570
+ ".facult/ai/index.json",
571
+ ".facult/ai/graph.json",
572
+ ];
573
+
574
+ const AUTOSYNC_MACHINE_LOCAL_LEGACY_PATHS = [
575
+ ".facult/managed.json",
576
+ ".facult/install.json",
577
+ ".facult/autosync",
578
+ ".facult/runtime",
579
+ ];
580
+
581
+ async function gitListTrackedPaths(
582
+ repoDir: string,
583
+ pathValue: string
584
+ ): Promise<string[]> {
585
+ const result = await runCommand(["git", "ls-files", "-z", "--", pathValue], {
586
+ cwd: repoDir,
587
+ });
588
+ if (result.exitCode !== 0 || !result.stdout) {
589
+ return [];
590
+ }
591
+ return result.stdout
592
+ .split("\0")
593
+ .map((value) => value.trim())
594
+ .filter(Boolean);
595
+ }
596
+
597
+ async function cleanupAutosyncProtectedPaths(repoDir: string): Promise<void> {
598
+ const tracked = new Set<string>();
599
+ for (const pathValue of [
600
+ ...AUTOSYNC_REBUILDABLE_PATHS,
601
+ ...AUTOSYNC_MACHINE_LOCAL_LEGACY_PATHS,
602
+ ]) {
603
+ for (const entry of await gitListTrackedPaths(repoDir, pathValue)) {
604
+ tracked.add(entry);
605
+ }
606
+ await rm(join(repoDir, pathValue), { force: true, recursive: true }).catch(
607
+ () => null
608
+ );
609
+ }
610
+
611
+ if (tracked.size > 0) {
612
+ await runCommand(
613
+ [
614
+ "git",
615
+ "restore",
616
+ "--staged",
617
+ "--worktree",
618
+ "--source=HEAD",
619
+ "--",
620
+ ...tracked,
621
+ ],
622
+ { cwd: repoDir }
623
+ );
624
+ }
625
+ }
626
+
473
627
  export async function runGitAutosyncOnce(args: {
474
628
  config: AutosyncServiceConfig;
475
629
  }): Promise<GitSyncOutcome> {
@@ -503,6 +657,8 @@ export async function runGitAutosyncOnce(args: {
503
657
  };
504
658
  }
505
659
 
660
+ await cleanupAutosyncProtectedPaths(repoDir);
661
+
506
662
  const fetch = await runCommand(
507
663
  ["git", "fetch", config.git.remote, config.git.branch],
508
664
  { cwd: repoDir }
@@ -849,10 +1005,18 @@ export async function installAutosyncService(args: {
849
1005
  await mkdir(dirname(spec.plistPath), { recursive: true });
850
1006
  await mkdir(autosyncLogsDir(home, rootDir), { recursive: true });
851
1007
  await saveAutosyncConfig(config, home);
1008
+ await cleanupLegacyAutosyncFiles({
1009
+ homeDir: home,
1010
+ serviceName,
1011
+ rootDir: config.rootDir,
1012
+ });
1013
+ await cleanupAutosyncLaunchAgentArtifacts({
1014
+ homeDir: home,
1015
+ serviceName,
1016
+ });
852
1017
  await writeFile(spec.plistPath, plist, "utf8");
853
1018
 
854
1019
  const domain = launchdDomain();
855
- await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(() => null);
856
1020
  await runLaunchctl(["bootstrap", domain, spec.plistPath]);
857
1021
  await runLaunchctl(["kickstart", "-k", `${domain}/${spec.label}`]);
858
1022
  return config;
@@ -868,10 +1032,16 @@ export async function uninstallAutosyncService(args: {
868
1032
  args.rootDir ??
869
1033
  resolveCliContextRoot({ homeDir: home, cwd: process.cwd() });
870
1034
  const serviceName = autosyncServiceName(args.tool, rootDir, home);
871
- const label = autosyncLabel(serviceName);
872
- const domain = launchdDomain();
873
1035
 
874
- await runLaunchctl(["bootout", `${domain}/${label}`]).catch(() => null);
1036
+ await cleanupAutosyncLaunchAgentArtifacts({
1037
+ homeDir: home,
1038
+ serviceName,
1039
+ });
1040
+ await cleanupLegacyAutosyncFiles({
1041
+ homeDir: home,
1042
+ serviceName,
1043
+ rootDir,
1044
+ });
875
1045
  await rm(autosyncPlistPath(home, serviceName), { force: true });
876
1046
  await rm(autosyncConfigPath(home, serviceName, rootDir), { force: true });
877
1047
  }
@@ -883,6 +1053,7 @@ export async function repairAutosyncServices(
883
1053
  const activeRoot = rootDir ?? facultRootDir(homeDir);
884
1054
  const serviceDirs = [
885
1055
  autosyncServicesDir(homeDir, activeRoot),
1056
+ join(canonicalAutosyncDir(homeDir, activeRoot), "services"),
886
1057
  legacyAutosyncServicesDir(homeDir, activeRoot),
887
1058
  ];
888
1059
  const seen = new Set<string>();
@@ -916,6 +1087,11 @@ export async function repairAutosyncServices(
916
1087
  await saveAutosyncConfig(config, homeDir);
917
1088
  changed = true;
918
1089
  }
1090
+ await cleanupLegacyAutosyncFiles({
1091
+ homeDir,
1092
+ serviceName,
1093
+ rootDir: config.rootDir,
1094
+ });
919
1095
 
920
1096
  const spec = buildLaunchAgentSpec({
921
1097
  homeDir,
@@ -923,19 +1099,27 @@ export async function repairAutosyncServices(
923
1099
  rootDir: config.rootDir,
924
1100
  });
925
1101
  const desired = buildLaunchAgentPlist(spec);
1102
+ const legacyPlistPath = join(
1103
+ homeDir,
1104
+ "Library",
1105
+ "LaunchAgents",
1106
+ `${legacyAutosyncLabel(serviceName)}.plist`
1107
+ );
926
1108
  const currentText = await readFile(spec.plistPath, "utf8").catch(
927
1109
  () => null
928
1110
  );
929
- if (currentText !== desired) {
1111
+ const legacyExists = await pathExists(legacyPlistPath);
1112
+ if (currentText !== desired || legacyExists) {
930
1113
  await mkdir(dirname(spec.plistPath), { recursive: true });
931
1114
  await mkdir(autosyncLogsDir(homeDir, config.rootDir), {
932
1115
  recursive: true,
933
1116
  });
1117
+ await cleanupAutosyncLaunchAgentArtifacts({
1118
+ homeDir,
1119
+ serviceName,
1120
+ });
934
1121
  await writeFile(spec.plistPath, desired, "utf8");
935
1122
  const domain = launchdDomain();
936
- await runLaunchctl(["bootout", `${domain}/${spec.label}`]).catch(
937
- () => null
938
- );
939
1123
  await runLaunchctl(["bootstrap", domain, spec.plistPath]).catch(
940
1124
  () => null
941
1125
  );
@@ -14,7 +14,7 @@ import {
14
14
  import {
15
15
  facultAiGraphPath,
16
16
  facultAiIndexPath,
17
- facultGeneratedStateDir,
17
+ facultMachineStateDir,
18
18
  facultRootDir,
19
19
  projectRootFromAiRoot,
20
20
  projectSlugFromAiRoot,
@@ -987,7 +987,7 @@ async function readManagedState(
987
987
  rootDir: string
988
988
  ): Promise<ManagedStateLite | null> {
989
989
  const statePath = join(
990
- facultGeneratedStateDir({ home: homeDir, rootDir }),
990
+ facultMachineStateDir(homeDir, rootDir),
991
991
  "managed.json"
992
992
  );
993
993
  try {
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"),
@@ -293,7 +296,7 @@ export function managedStatePathForRoot(
293
296
  home: string = homedir(),
294
297
  rootDir?: string
295
298
  ): string {
296
- return join(facultGeneratedStateDir({ home, rootDir }), "managed.json");
299
+ return join(facultMachineStateDir(home, rootDir), "managed.json");
297
300
  }
298
301
 
299
302
  function legacyManagedStatePathForRoot(
@@ -336,7 +339,7 @@ export async function saveManagedState(
336
339
  home: string = homedir(),
337
340
  rootDir?: string
338
341
  ) {
339
- const dir = facultGeneratedStateDir({ home, rootDir });
342
+ const dir = facultMachineStateDir(home, rootDir);
340
343
  await ensureDir(dir);
341
344
  await Bun.write(
342
345
  managedStatePathForRoot(home, rootDir),
@@ -433,6 +436,104 @@ async function loadCanonicalAgents(
433
436
  return await loadAgentsFromRoot(homePath(rootDir, "agents"));
434
437
  }
435
438
 
439
+ interface AutomationEntry {
440
+ name: string;
441
+ sourceDir: string;
442
+ files: Map<string, string>;
443
+ }
444
+
445
+ async function listRelativeFiles(root: string): Promise<string[]> {
446
+ const out: string[] = [];
447
+
448
+ async function visit(currentDir: string, prefix = ""): Promise<void> {
449
+ const entries = await readdir(currentDir, { withFileTypes: true }).catch(
450
+ () => [] as import("node:fs").Dirent[]
451
+ );
452
+ for (const entry of entries) {
453
+ if (entry.name.startsWith(".")) {
454
+ continue;
455
+ }
456
+ const relPath = prefix ? join(prefix, entry.name) : entry.name;
457
+ const fullPath = join(currentDir, entry.name);
458
+ if (entry.isDirectory()) {
459
+ await visit(fullPath, relPath);
460
+ continue;
461
+ }
462
+ if (entry.isFile()) {
463
+ out.push(relPath);
464
+ }
465
+ }
466
+ }
467
+
468
+ await visit(root);
469
+ return out.sort();
470
+ }
471
+
472
+ async function loadAutomationEntries(
473
+ automationsRoot: string
474
+ ): Promise<AutomationEntry[]> {
475
+ const entries = await readdir(automationsRoot, { withFileTypes: true }).catch(
476
+ () => [] as import("node:fs").Dirent[]
477
+ );
478
+ const out: AutomationEntry[] = [];
479
+
480
+ for (const entry of entries) {
481
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
482
+ continue;
483
+ }
484
+ const sourceDir = join(automationsRoot, entry.name);
485
+ const relativeFiles = await listRelativeFiles(sourceDir);
486
+ const files = new Map<string, string>();
487
+ for (const relPath of relativeFiles) {
488
+ const raw = await readTextIfExists(join(sourceDir, relPath));
489
+ if (raw == null) {
490
+ continue;
491
+ }
492
+ files.set(relPath, raw);
493
+ }
494
+ if (!files.has("automation.toml")) {
495
+ continue;
496
+ }
497
+ out.push({
498
+ name: entry.name,
499
+ sourceDir,
500
+ files,
501
+ });
502
+ }
503
+
504
+ return out.sort((a, b) => a.name.localeCompare(b.name));
505
+ }
506
+
507
+ async function loadCanonicalAutomations(
508
+ rootDir: string
509
+ ): Promise<AutomationEntry[]> {
510
+ return await loadAutomationEntries(join(rootDir, "automations"));
511
+ }
512
+
513
+ function automationEntriesEqual(
514
+ left: AutomationEntry,
515
+ right: AutomationEntry
516
+ ): boolean {
517
+ if (left.files.size !== right.files.size) {
518
+ return false;
519
+ }
520
+ for (const [relPath, leftRaw] of left.files.entries()) {
521
+ if (right.files.get(relPath) !== leftRaw) {
522
+ return false;
523
+ }
524
+ }
525
+ return true;
526
+ }
527
+
528
+ async function canonicalAutomationsExist(rootDir: string): Promise<boolean> {
529
+ try {
530
+ const automations = await loadCanonicalAutomations(rootDir);
531
+ return automations.length > 0;
532
+ } catch {
533
+ return false;
534
+ }
535
+ }
536
+
436
537
  async function loadMergedIndex(
437
538
  homeDir: string,
438
539
  rootDir: string
@@ -624,6 +725,58 @@ async function syncAgentFiles({
624
725
  return { add: plan.add, remove: plan.remove };
625
726
  }
626
727
 
728
+ async function planAutomationFileChanges(args: {
729
+ automationDir: string;
730
+ rootDir: string;
731
+ previouslyManagedTargets?: string[];
732
+ }): Promise<{
733
+ add: string[];
734
+ remove: string[];
735
+ contents: Map<string, string>;
736
+ sources: Map<string, string>;
737
+ }> {
738
+ const automations = await loadCanonicalAutomations(args.rootDir);
739
+ const contents = new Map<string, string>();
740
+ const sources = new Map<string, string>();
741
+ const desiredPaths = new Set<string>();
742
+
743
+ for (const automation of automations) {
744
+ for (const [relPath, raw] of automation.files.entries()) {
745
+ const targetPath = join(args.automationDir, automation.name, relPath);
746
+ const sourcePath = join(automation.sourceDir, relPath);
747
+ desiredPaths.add(targetPath);
748
+ contents.set(targetPath, raw);
749
+ sources.set(targetPath, sourcePath);
750
+ }
751
+ }
752
+
753
+ const add = new Set<string>();
754
+ for (const targetPath of desiredPaths) {
755
+ const current = await readTextIfExists(targetPath);
756
+ const desired = contents.get(targetPath);
757
+ if (desired != null && current !== desired) {
758
+ add.add(targetPath);
759
+ }
760
+ }
761
+
762
+ const remove = Array.from(
763
+ new Set(
764
+ (args.previouslyManagedTargets ?? []).filter(
765
+ (targetPath) =>
766
+ targetPath.startsWith(join(args.automationDir, "")) &&
767
+ !desiredPaths.has(targetPath)
768
+ )
769
+ )
770
+ ).sort();
771
+
772
+ return {
773
+ add: Array.from(add).sort(),
774
+ remove,
775
+ contents,
776
+ sources,
777
+ };
778
+ }
779
+
627
780
  async function listSkillDirs(skillsRoot: string): Promise<string[]> {
628
781
  try {
629
782
  const entries = await readdir(skillsRoot, { withFileTypes: true });
@@ -945,6 +1098,7 @@ interface ExistingManagedItem {
945
1098
  kind:
946
1099
  | "skill"
947
1100
  | "agent"
1101
+ | "automation"
948
1102
  | "global-doc"
949
1103
  | "rule"
950
1104
  | "tool-config"
@@ -1123,6 +1277,87 @@ async function adoptExistingToolAgents(args: {
1123
1277
  return adopted;
1124
1278
  }
1125
1279
 
1280
+ async function planExistingAutomationAdoption(args: {
1281
+ rootDir: string;
1282
+ automationDir: string;
1283
+ }): Promise<ExistingManagedImportPlan> {
1284
+ const plan = emptyManagedImportPlan();
1285
+ const liveAutomations = await loadAutomationEntries(args.automationDir);
1286
+ const canonicalAutomations = new Map(
1287
+ (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1288
+ entry.name,
1289
+ entry,
1290
+ ])
1291
+ );
1292
+
1293
+ for (const liveAutomation of liveAutomations) {
1294
+ const canonicalAutomation = canonicalAutomations.get(liveAutomation.name);
1295
+ if (!canonicalAutomation) {
1296
+ continue;
1297
+ }
1298
+ const item: ExistingManagedItem = {
1299
+ kind: "automation",
1300
+ name: liveAutomation.name,
1301
+ livePath: liveAutomation.sourceDir,
1302
+ canonicalPath: join(args.rootDir, "automations", liveAutomation.name),
1303
+ };
1304
+ if (automationEntriesEqual(liveAutomation, canonicalAutomation)) {
1305
+ plan.identical.push(item);
1306
+ } else {
1307
+ plan.conflicts.push(item);
1308
+ }
1309
+ }
1310
+
1311
+ return mergeManagedImportPlans(plan);
1312
+ }
1313
+
1314
+ async function adoptExistingAutomations(args: {
1315
+ rootDir: string;
1316
+ automationDir: string;
1317
+ conflictMode: "keep-canonical" | "keep-existing";
1318
+ }): Promise<ExistingManagedItem[]> {
1319
+ if (args.conflictMode !== "keep-existing") {
1320
+ return [];
1321
+ }
1322
+
1323
+ const adopted: ExistingManagedItem[] = [];
1324
+ const liveAutomations = await loadAutomationEntries(args.automationDir);
1325
+ const canonicalAutomations = new Map(
1326
+ (await loadCanonicalAutomations(args.rootDir)).map((entry) => [
1327
+ entry.name,
1328
+ entry,
1329
+ ])
1330
+ );
1331
+
1332
+ for (const liveAutomation of liveAutomations) {
1333
+ const canonicalAutomation = canonicalAutomations.get(liveAutomation.name);
1334
+ if (
1335
+ !(
1336
+ canonicalAutomation &&
1337
+ !automationEntriesEqual(liveAutomation, canonicalAutomation)
1338
+ )
1339
+ ) {
1340
+ continue;
1341
+ }
1342
+ const canonicalPath = join(
1343
+ args.rootDir,
1344
+ "automations",
1345
+ liveAutomation.name
1346
+ );
1347
+ await ensureDir(dirname(canonicalPath));
1348
+ await rm(canonicalPath, { recursive: true, force: true });
1349
+ await cp(liveAutomation.sourceDir, canonicalPath, { recursive: true });
1350
+ adopted.push({
1351
+ kind: "automation",
1352
+ name: liveAutomation.name,
1353
+ livePath: liveAutomation.sourceDir,
1354
+ canonicalPath,
1355
+ });
1356
+ }
1357
+
1358
+ return adopted;
1359
+ }
1360
+
1126
1361
  async function planExistingGlobalDocAdoption(args: {
1127
1362
  rootDir: string;
1128
1363
  tool: string;
@@ -1788,6 +2023,12 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1788
2023
  agentsDir: toolPaths.agentsDir,
1789
2024
  })
1790
2025
  : emptyManagedImportPlan(),
2026
+ toolPaths.automationDir
2027
+ ? await planExistingAutomationAdoption({
2028
+ rootDir,
2029
+ automationDir: toolPaths.automationDir,
2030
+ })
2031
+ : emptyManagedImportPlan(),
1791
2032
  toolPaths.toolHome
1792
2033
  ? await planExistingGlobalDocAdoption({
1793
2034
  rootDir,
@@ -1826,6 +2067,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1826
2067
  if (
1827
2068
  (toolPaths.skillsDir ||
1828
2069
  toolPaths.agentsDir ||
2070
+ toolPaths.automationDir ||
1829
2071
  toolPaths.toolHome ||
1830
2072
  toolPaths.rulesDir ||
1831
2073
  toolPaths.toolConfig ||
@@ -1906,6 +2148,14 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1906
2148
  });
1907
2149
  adoptedSkills.push(...result.map((item) => item.name));
1908
2150
  }
2151
+ if (toolPaths.automationDir && opts.adoptExisting) {
2152
+ const result = await adoptExistingAutomations({
2153
+ rootDir,
2154
+ automationDir: toolPaths.automationDir,
2155
+ conflictMode: importConflictMode,
2156
+ });
2157
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
2158
+ }
1909
2159
  if (toolPaths.toolHome && opts.adoptExisting) {
1910
2160
  const result = await adoptExistingGlobalDocs({
1911
2161
  rootDir,
@@ -1957,6 +2207,12 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1957
2207
  tool,
1958
2208
  })
1959
2209
  : null;
2210
+ const automationPreview = toolPaths.automationDir
2211
+ ? await planAutomationFileChanges({
2212
+ automationDir: toolPaths.automationDir,
2213
+ rootDir,
2214
+ })
2215
+ : null;
1960
2216
  const globalDocsPreview = toolPaths.toolHome
1961
2217
  ? await planToolGlobalDocsSync({
1962
2218
  homeDir: home,
@@ -2048,6 +2304,16 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2048
2304
  });
2049
2305
  }
2050
2306
 
2307
+ if (toolPaths.automationDir && automationPreview) {
2308
+ await ensureDir(toolPaths.automationDir);
2309
+ await applyRenderedRemoves(automationPreview.remove);
2310
+ await applyRenderedWrites({
2311
+ contents: automationPreview.contents,
2312
+ targets: Array.from(automationPreview.contents.keys()),
2313
+ });
2314
+ await pruneEmptyParents(automationPreview.remove, toolPaths.automationDir);
2315
+ }
2316
+
2051
2317
  if (toolPaths.toolHome && globalDocsPreview) {
2052
2318
  await ensureDir(toolPaths.toolHome);
2053
2319
  await applyRenderedRemoves(globalDocsPreview.remove);
@@ -2086,6 +2352,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2086
2352
  skillsDir: toolPaths.skillsDir,
2087
2353
  mcpConfig: toolPaths.mcpConfig,
2088
2354
  agentsDir: toolPaths.agentsDir,
2355
+ automationDir: toolPaths.automationDir,
2089
2356
  toolHome: globalDocsPreview?.managedTargets.length
2090
2357
  ? toolPaths.toolHome
2091
2358
  : undefined,
@@ -2123,6 +2390,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2123
2390
  sources: agentPreview.sources,
2124
2391
  });
2125
2392
  }
2393
+ if (automationPreview) {
2394
+ updateRenderedTargetState({
2395
+ entry: managedEntry,
2396
+ writtenTargets: Array.from(automationPreview.contents.keys()),
2397
+ removedTargets: automationPreview.remove,
2398
+ contents: automationPreview.contents,
2399
+ sources: automationPreview.sources,
2400
+ });
2401
+ }
2126
2402
  if (globalDocsPreview) {
2127
2403
  updateRenderedTargetState({
2128
2404
  entry: managedEntry,
@@ -2238,6 +2514,14 @@ export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
2238
2514
  });
2239
2515
  }
2240
2516
 
2517
+ if (entry.automationDir) {
2518
+ const automationTargets = Object.keys(entry.renderedTargets ?? {}).filter(
2519
+ (targetPath) => targetPath.startsWith(join(entry.automationDir!, ""))
2520
+ );
2521
+ await applyRenderedRemoves(automationTargets);
2522
+ await pruneEmptyParents(automationTargets, entry.automationDir);
2523
+ }
2524
+
2241
2525
  if (entry.globalAgentsPath) {
2242
2526
  await restoreBackup({
2243
2527
  original: entry.globalAgentsPath,
@@ -2320,6 +2604,15 @@ async function repairManagedToolEntry(args: {
2320
2604
  changed = true;
2321
2605
  }
2322
2606
 
2607
+ if (
2608
+ !next.automationDir &&
2609
+ toolPaths.automationDir &&
2610
+ (await canonicalAutomationsExist(rootDir))
2611
+ ) {
2612
+ next.automationDir = toolPaths.automationDir;
2613
+ changed = true;
2614
+ }
2615
+
2323
2616
  if (toolPaths.toolHome && !next.toolHome) {
2324
2617
  const preview = await syncToolGlobalDocs({
2325
2618
  homeDir,
@@ -2406,6 +2699,7 @@ async function planRenderedTargetConflicts(args: {
2406
2699
  desiredContents: Map<string, string>;
2407
2700
  desiredSources: Map<string, string>;
2408
2701
  conflictMode?: "warn" | "overwrite";
2702
+ protectAllSources?: boolean;
2409
2703
  }): Promise<RenderedApplyPlan> {
2410
2704
  if (args.conflictMode === "overwrite") {
2411
2705
  return {
@@ -2433,7 +2727,7 @@ async function planRenderedTargetConflicts(args: {
2433
2727
  continue;
2434
2728
  }
2435
2729
  const sourceKind = renderedSourceKindForPath(sourcePath);
2436
- if (sourceKind !== "builtin") {
2730
+ if (sourceKind !== "builtin" && !args.protectAllSources) {
2437
2731
  if (args.desiredWrites.includes(targetPath)) {
2438
2732
  write.push(targetPath);
2439
2733
  } else {
@@ -2452,8 +2746,16 @@ async function planRenderedTargetConflicts(args: {
2452
2746
  }
2453
2747
 
2454
2748
  const currentHash = renderedHash(current);
2749
+ const desiredHash = args.desiredContents.get(targetPath)
2750
+ ? renderedHash(args.desiredContents.get(targetPath)!)
2751
+ : null;
2455
2752
  if (prior?.hash) {
2456
- if (currentHash === prior.hash) {
2753
+ if (
2754
+ currentHash === prior.hash ||
2755
+ (args.desiredWrites.includes(targetPath) &&
2756
+ desiredHash != null &&
2757
+ currentHash === desiredHash)
2758
+ ) {
2457
2759
  if (args.desiredWrites.includes(targetPath)) {
2458
2760
  write.push(targetPath);
2459
2761
  } else {
@@ -2470,6 +2772,15 @@ async function planRenderedTargetConflicts(args: {
2470
2772
  continue;
2471
2773
  }
2472
2774
 
2775
+ if (
2776
+ args.desiredWrites.includes(targetPath) &&
2777
+ desiredHash != null &&
2778
+ currentHash === desiredHash
2779
+ ) {
2780
+ write.push(targetPath);
2781
+ continue;
2782
+ }
2783
+
2473
2784
  conflicts.push({
2474
2785
  targetPath,
2475
2786
  sourcePath,
@@ -2496,8 +2807,14 @@ function logRenderedConflicts(
2496
2807
  conflict.reason === "unknown_state"
2497
2808
  ? "no prior managed hash is recorded"
2498
2809
  : "local edits were detected";
2810
+ const surface =
2811
+ conflict.sourceKind === "builtin"
2812
+ ? "builtin-backed target"
2813
+ : "managed target";
2499
2814
  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.`
2815
+ conflict.sourceKind === "builtin"
2816
+ ? `${tool}: ${verb} ${surface} ${conflict.targetPath} because ${state}. Rerun with "--builtin-conflicts overwrite" to replace it with the latest packaged default.`
2817
+ : `${tool}: ${verb} ${surface} ${conflict.targetPath} because ${state}.`
2501
2818
  );
2502
2819
  }
2503
2820
  }
@@ -2525,6 +2842,24 @@ async function applyRenderedRemoves(targets: string[]) {
2525
2842
  }
2526
2843
  }
2527
2844
 
2845
+ async function pruneEmptyParents(targets: string[], stopDir: string) {
2846
+ const candidateDirs = Array.from(
2847
+ new Set(targets.map((pathValue) => dirname(pathValue)))
2848
+ ).sort((a, b) => b.length - a.length);
2849
+
2850
+ for (const startDir of candidateDirs) {
2851
+ let currentDir = startDir;
2852
+ while (currentDir.startsWith(join(stopDir, "")) && currentDir !== stopDir) {
2853
+ const entries = await readdir(currentDir).catch(() => null);
2854
+ if (!(entries && entries.length === 0)) {
2855
+ break;
2856
+ }
2857
+ await rm(currentDir, { recursive: true, force: true });
2858
+ currentDir = dirname(currentDir);
2859
+ }
2860
+ }
2861
+ }
2862
+
2528
2863
  function updateRenderedTargetState(args: {
2529
2864
  entry: ManagedToolState;
2530
2865
  writtenTargets: string[];
@@ -2558,6 +2893,8 @@ function logSyncDryRun({
2558
2893
  mcpPlan,
2559
2894
  agentPlan,
2560
2895
  agentConflicts,
2896
+ automationPlan,
2897
+ automationConflicts,
2561
2898
  globalDocsPlan,
2562
2899
  globalDocsConflicts,
2563
2900
  rulesPlan,
@@ -2571,6 +2908,8 @@ function logSyncDryRun({
2571
2908
  mcpPlan: { needsWrite: boolean };
2572
2909
  agentPlan: { add: string[]; remove: string[] };
2573
2910
  agentConflicts: RenderedConflict[];
2911
+ automationPlan: { write: string[]; remove: string[] };
2912
+ automationConflicts: RenderedConflict[];
2574
2913
  globalDocsPlan: { write: string[]; remove: string[] };
2575
2914
  globalDocsConflicts: RenderedConflict[];
2576
2915
  rulesPlan: { write: string[]; remove: string[] };
@@ -2591,6 +2930,13 @@ function logSyncDryRun({
2591
2930
  console.log(`${tool}: would remove agent ${p}`);
2592
2931
  }
2593
2932
  logRenderedConflicts(tool, agentConflicts, true);
2933
+ for (const p of automationPlan.write) {
2934
+ console.log(`${tool}: would write automation ${p}`);
2935
+ }
2936
+ for (const p of automationPlan.remove) {
2937
+ console.log(`${tool}: would remove automation ${p}`);
2938
+ }
2939
+ logRenderedConflicts(tool, automationConflicts, true);
2594
2940
  for (const p of globalDocsPlan.write) {
2595
2941
  console.log(`${tool}: would write global doc ${p}`);
2596
2942
  }
@@ -2620,6 +2966,8 @@ function logSyncDryRun({
2620
2966
  skillPlan.remove.length === 0 &&
2621
2967
  agentPlan.add.length === 0 &&
2622
2968
  agentPlan.remove.length === 0 &&
2969
+ automationPlan.write.length === 0 &&
2970
+ automationPlan.remove.length === 0 &&
2623
2971
  globalDocsPlan.write.length === 0 &&
2624
2972
  globalDocsPlan.remove.length === 0 &&
2625
2973
  rulesPlan.write.length === 0 &&
@@ -2628,6 +2976,7 @@ function logSyncDryRun({
2628
2976
  !configPlan.remove &&
2629
2977
  !mcpPlan.needsWrite &&
2630
2978
  agentConflicts.length === 0 &&
2979
+ automationConflicts.length === 0 &&
2631
2980
  globalDocsConflicts.length === 0 &&
2632
2981
  rulesConflicts.length === 0 &&
2633
2982
  configConflicts.length === 0
@@ -2767,6 +3116,13 @@ async function syncManagedToolEntry({
2767
3116
  tool,
2768
3117
  })
2769
3118
  : { add: [], remove: [], contents: new Map(), sources: new Map() };
3119
+ const automationPlan = entry.automationDir
3120
+ ? await planAutomationFileChanges({
3121
+ automationDir: entry.automationDir,
3122
+ rootDir,
3123
+ previouslyManagedTargets: Object.keys(entry.renderedTargets ?? {}),
3124
+ })
3125
+ : { add: [], remove: [], contents: new Map(), sources: new Map() };
2770
3126
 
2771
3127
  const mcpPlan = entry.mcpConfig
2772
3128
  ? await syncMcpConfig({
@@ -2846,6 +3202,15 @@ async function syncManagedToolEntry({
2846
3202
  desiredSources: globalDocsPlan.sources,
2847
3203
  conflictMode: builtinConflictMode,
2848
3204
  });
3205
+ const automationRendered = await planRenderedTargetConflicts({
3206
+ entry,
3207
+ desiredWrites: automationPlan.add,
3208
+ desiredRemoves: automationPlan.remove,
3209
+ desiredContents: automationPlan.contents,
3210
+ desiredSources: automationPlan.sources,
3211
+ conflictMode: builtinConflictMode,
3212
+ protectAllSources: true,
3213
+ });
2849
3214
  const rulesRendered = await planRenderedTargetConflicts({
2850
3215
  entry,
2851
3216
  desiredWrites: rulesPlan.write,
@@ -2882,6 +3247,11 @@ async function syncManagedToolEntry({
2882
3247
  mcpPlan,
2883
3248
  agentPlan: { add: agentRendered.write, remove: agentRendered.remove },
2884
3249
  agentConflicts: agentRendered.conflicts,
3250
+ automationPlan: {
3251
+ write: automationRendered.write,
3252
+ remove: automationRendered.remove,
3253
+ },
3254
+ automationConflicts: automationRendered.conflicts,
2885
3255
  globalDocsPlan: {
2886
3256
  write: globalDocsRendered.write,
2887
3257
  remove: globalDocsRendered.remove,
@@ -2902,6 +3272,14 @@ async function syncManagedToolEntry({
2902
3272
  contents: agentPlan.contents,
2903
3273
  targets: agentRendered.write,
2904
3274
  });
3275
+ await applyRenderedRemoves(automationRendered.remove);
3276
+ await applyRenderedWrites({
3277
+ contents: automationPlan.contents,
3278
+ targets: automationRendered.write,
3279
+ });
3280
+ if (entry.automationDir) {
3281
+ await pruneEmptyParents(automationRendered.remove, entry.automationDir);
3282
+ }
2905
3283
  await applyRenderedRemoves(globalDocsRendered.remove);
2906
3284
  await applyRenderedWrites({
2907
3285
  contents: globalDocsPlan.contents,
@@ -2918,6 +3296,7 @@ async function syncManagedToolEntry({
2918
3296
  targets: configRendered.write,
2919
3297
  });
2920
3298
  logRenderedConflicts(tool, agentRendered.conflicts);
3299
+ logRenderedConflicts(tool, automationRendered.conflicts);
2921
3300
  logRenderedConflicts(tool, globalDocsRendered.conflicts);
2922
3301
  logRenderedConflicts(tool, rulesRendered.conflicts);
2923
3302
  logRenderedConflicts(tool, configRendered.conflicts);
@@ -2929,6 +3308,13 @@ async function syncManagedToolEntry({
2929
3308
  contents: agentPlan.contents,
2930
3309
  sources: agentPlan.sources,
2931
3310
  });
3311
+ updateRenderedTargetState({
3312
+ entry,
3313
+ writtenTargets: automationRendered.write,
3314
+ removedTargets: automationRendered.remove,
3315
+ contents: automationPlan.contents,
3316
+ sources: automationPlan.sources,
3317
+ });
2932
3318
  updateRenderedTargetState({
2933
3319
  entry,
2934
3320
  writtenTargets: globalDocsRendered.write,
package/src/paths.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, join, resolve } from "node:path";
@@ -93,6 +94,34 @@ export function preferredGlobalFacultStateDir(
93
94
  return join(preferredGlobalAiRoot(home), ".facult");
94
95
  }
95
96
 
97
+ export function facultLocalStateRoot(home: string = defaultHomeDir()): string {
98
+ const override = process.env.FACULT_LOCAL_STATE_DIR?.trim();
99
+ if (override) {
100
+ return resolvePath(override, home);
101
+ }
102
+ if (process.platform === "darwin") {
103
+ return join(home, "Library", "Application Support", "fclt");
104
+ }
105
+ const xdg = process.env.XDG_STATE_HOME?.trim();
106
+ return xdg
107
+ ? join(resolvePath(xdg, home), "fclt")
108
+ : join(home, ".local", "state", "fclt");
109
+ }
110
+
111
+ export function facultLocalCacheRoot(home: string = defaultHomeDir()): string {
112
+ const override = process.env.FACULT_CACHE_DIR?.trim();
113
+ if (override) {
114
+ return resolvePath(override, home);
115
+ }
116
+ if (process.platform === "darwin") {
117
+ return join(home, "Library", "Caches", "fclt");
118
+ }
119
+ const xdg = process.env.XDG_CACHE_HOME?.trim();
120
+ return xdg
121
+ ? join(resolvePath(xdg, home), "fclt")
122
+ : join(home, ".cache", "fclt");
123
+ }
124
+
96
125
  export function legacyExternalFacultStateDir(
97
126
  home: string = defaultHomeDir()
98
127
  ): string {
@@ -186,6 +215,46 @@ export function facultStateDir(
186
215
  return join(resolvedRoot, ".facult");
187
216
  }
188
217
 
218
+ function machineStateProjectKey(
219
+ rootDir: string,
220
+ home: string = defaultHomeDir()
221
+ ): string {
222
+ const projectRoot = projectRootFromAiRoot(rootDir, home);
223
+ const labelSource = projectRoot ?? rootDir;
224
+ const label = basename(labelSource).trim().toLowerCase();
225
+ const slug = label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
226
+ const digest = createHash("sha256")
227
+ .update(resolve(rootDir))
228
+ .digest("hex")
229
+ .slice(0, 12);
230
+ return `${slug || "project"}-${digest}`;
231
+ }
232
+
233
+ export function facultMachineStateDir(
234
+ home: string = defaultHomeDir(),
235
+ rootDir?: string
236
+ ): string {
237
+ const resolvedRoot = rootDir ?? facultRootDir(home);
238
+ const projectRoot = projectRootFromAiRoot(resolvedRoot, home);
239
+ return projectRoot
240
+ ? join(
241
+ facultLocalStateRoot(home),
242
+ "projects",
243
+ machineStateProjectKey(resolvedRoot, home)
244
+ )
245
+ : join(facultLocalStateRoot(home), "global");
246
+ }
247
+
248
+ export function facultInstallStatePath(
249
+ home: string = defaultHomeDir()
250
+ ): string {
251
+ return join(facultLocalStateRoot(home), "install.json");
252
+ }
253
+
254
+ export function facultRuntimeCacheDir(home: string = defaultHomeDir()): string {
255
+ return join(facultLocalCacheRoot(home), "runtime");
256
+ }
257
+
189
258
  export function projectRootFromAiRoot(
190
259
  rootDir: string,
191
260
  home: string = defaultHomeDir()
@@ -2,6 +2,7 @@ import { mkdir, rename } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import {
5
+ facultInstallStatePath,
5
6
  legacyExternalFacultStateDir,
6
7
  preferredGlobalFacultStateDir,
7
8
  } from "./paths";
@@ -88,6 +89,7 @@ export function parseSelfUpdateArgs(argv: string[]): ParsedArgs {
88
89
 
89
90
  async function loadInstallState(home: string): Promise<InstallState | null> {
90
91
  const paths = [
92
+ facultInstallStatePath(home),
91
93
  join(preferredGlobalFacultStateDir(home), "install.json"),
92
94
  join(legacyExternalFacultStateDir(home), "install.json"),
93
95
  ];
@@ -208,7 +210,7 @@ async function writeInstallState(args: {
208
210
  packageVersion?: string;
209
211
  binaryPath?: string;
210
212
  }) {
211
- const dir = preferredGlobalFacultStateDir(args.home);
213
+ const dir = dirname(facultInstallStatePath(args.home));
212
214
  await mkdir(dir, { recursive: true });
213
215
  const payload: InstallState = {
214
216
  version: 1,
@@ -219,7 +221,7 @@ async function writeInstallState(args: {
219
221
  installedAt: new Date().toISOString(),
220
222
  };
221
223
  await Bun.write(
222
- join(dir, "install.json"),
224
+ facultInstallStatePath(args.home),
223
225
  `${JSON.stringify(payload, null, 2)}\n`
224
226
  );
225
227
  }