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/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.5.0",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,228 @@
1
+ import { basename, extname } from "node:path";
2
+ import { renderCanonicalText } from "../agents";
3
+ import { generateMcpConfig, parseMcpConfig } from "./mcp";
4
+ import { parseSkillsDir } from "./skills";
5
+ import type {
6
+ CanonicalMcpConfig,
7
+ CanonicalMcpServer,
8
+ ParsedManagedAgentFile,
9
+ RenderManagedAgentOptions,
10
+ ToolAdapter,
11
+ } from "./types";
12
+ import { detectExplicitVersion } from "./version";
13
+
14
+ const FRONTMATTER_LINE_SPLIT_REGEX = /\r?\n/;
15
+ const FACTORY_AGENT_FRONTMATTER_REGEX =
16
+ /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
17
+ const LEADING_WHITESPACE_REGEX = /^\s+/;
18
+ const TRAILING_WHITESPACE_REGEX = /\s+$/;
19
+
20
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
21
+ return !!value && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function escapeTomlMultiline(value: string): string {
25
+ return value.replace(/"""/g, '\\"""');
26
+ }
27
+
28
+ function escapeYamlString(value: string): string {
29
+ return JSON.stringify(value);
30
+ }
31
+
32
+ function stringifyFrontmatter(values: Record<string, string>): string {
33
+ return Object.entries(values)
34
+ .map(([key, value]) => `${key}: ${escapeYamlString(value)}`)
35
+ .join("\n");
36
+ }
37
+
38
+ function parseFrontmatterScalar(value: string): string {
39
+ const trimmed = value.trim();
40
+ if (!trimmed) {
41
+ return "";
42
+ }
43
+ if (
44
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
45
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
46
+ ) {
47
+ const quote = trimmed[0];
48
+ const inner = trimmed.slice(1, -1);
49
+ if (quote === '"') {
50
+ try {
51
+ return JSON.parse(trimmed);
52
+ } catch {
53
+ return inner;
54
+ }
55
+ }
56
+ return inner;
57
+ }
58
+ return trimmed;
59
+ }
60
+
61
+ function parseFrontmatter(text: string): Record<string, string> {
62
+ const out: Record<string, string> = {};
63
+ for (const line of text.split(FRONTMATTER_LINE_SPLIT_REGEX)) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed || trimmed.startsWith("#")) {
66
+ continue;
67
+ }
68
+ const separator = trimmed.indexOf(":");
69
+ if (separator === -1) {
70
+ continue;
71
+ }
72
+ const key = trimmed.slice(0, separator).trim();
73
+ const value = parseFrontmatterScalar(trimmed.slice(separator + 1));
74
+ if (key) {
75
+ out[key] = value;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function normalizeFactoryServer(
82
+ server: CanonicalMcpServer
83
+ ): CanonicalMcpServer {
84
+ if (!isPlainObject(server.vendorExtensions)) {
85
+ return server;
86
+ }
87
+
88
+ const { type, ...vendorExtensions } = server.vendorExtensions;
89
+ return {
90
+ ...server,
91
+ transport:
92
+ typeof type === "string" && !server.transport ? type : server.transport,
93
+ vendorExtensions:
94
+ Object.keys(vendorExtensions).length > 0 ? vendorExtensions : undefined,
95
+ };
96
+ }
97
+
98
+ function parseFactoryMcp(config: unknown): CanonicalMcpConfig {
99
+ const parsed = parseMcpConfig(config);
100
+ for (const [name, server] of Object.entries(parsed.servers)) {
101
+ parsed.servers[name] = normalizeFactoryServer({ ...server });
102
+ }
103
+ return parsed;
104
+ }
105
+
106
+ function generateFactoryMcp(
107
+ canonical: CanonicalMcpConfig
108
+ ): Record<string, unknown> {
109
+ const generated = generateMcpConfig(canonical, "mcpServers");
110
+ const servers = generated.mcpServers;
111
+ if (!isPlainObject(servers)) {
112
+ return generated;
113
+ }
114
+
115
+ for (const [name, value] of Object.entries(servers)) {
116
+ if (!isPlainObject(value)) {
117
+ continue;
118
+ }
119
+ const { transport, ...server } = value as Record<string, unknown>;
120
+ const inferredType =
121
+ (typeof transport === "string" ? transport : undefined) ??
122
+ (typeof server.url === "string"
123
+ ? "http"
124
+ : typeof server.command === "string"
125
+ ? "stdio"
126
+ : undefined);
127
+ if (inferredType && typeof server.type !== "string") {
128
+ server.type = inferredType;
129
+ }
130
+ if (typeof server.disabled !== "boolean") {
131
+ server.disabled = false;
132
+ }
133
+ servers[name] = server;
134
+ }
135
+
136
+ return generated;
137
+ }
138
+
139
+ async function renderFactoryAgent(
140
+ options: RenderManagedAgentOptions
141
+ ): Promise<string> {
142
+ const parsed = Bun.TOML.parse(options.raw) as Record<string, unknown>;
143
+ const name =
144
+ typeof parsed.name === "string"
145
+ ? parsed.name
146
+ : basename(options.targetPath, extname(options.targetPath));
147
+ const description =
148
+ typeof parsed.description === "string" ? parsed.description : undefined;
149
+ const instructions =
150
+ typeof parsed.developer_instructions === "string"
151
+ ? parsed.developer_instructions
152
+ : "";
153
+ const renderedInstructions = await renderCanonicalText(instructions, {
154
+ homeDir: options.homeDir,
155
+ rootDir: options.rootDir,
156
+ projectRoot: options.projectRoot,
157
+ targetTool: options.tool,
158
+ targetPath: options.targetPath,
159
+ });
160
+
161
+ const frontmatter = stringifyFrontmatter({
162
+ name,
163
+ ...(description ? { description } : {}),
164
+ model: "inherit",
165
+ });
166
+ const body = renderedInstructions.trim();
167
+
168
+ return body
169
+ ? `---\n${frontmatter}\n---\n\n${body}\n`
170
+ : `---\n${frontmatter}\n---\n`;
171
+ }
172
+
173
+ async function parseFactoryManagedAgentFile(
174
+ path: string
175
+ ): Promise<ParsedManagedAgentFile | null> {
176
+ const file = Bun.file(path);
177
+ if (!(await file.exists())) {
178
+ return null;
179
+ }
180
+
181
+ const raw = await file.text();
182
+ const match = raw.match(FACTORY_AGENT_FRONTMATTER_REGEX);
183
+ if (!match) {
184
+ return null;
185
+ }
186
+
187
+ const [, frontmatterRaw, bodyRaw] = match;
188
+ const frontmatter = parseFrontmatter(frontmatterRaw ?? "");
189
+ const name = frontmatter.name || basename(path, extname(path));
190
+ const description = frontmatter.description || undefined;
191
+ const body = (bodyRaw ?? "")
192
+ .replace(LEADING_WHITESPACE_REGEX, "")
193
+ .replace(TRAILING_WHITESPACE_REGEX, "");
194
+ const lines = [`name = ${JSON.stringify(name)}`];
195
+ if (description) {
196
+ lines.push(`description = ${JSON.stringify(description)}`);
197
+ }
198
+ lines.push("", 'developer_instructions = """');
199
+ if (body) {
200
+ lines.push(escapeTomlMultiline(body));
201
+ }
202
+ lines.push('"""', "");
203
+
204
+ return {
205
+ name,
206
+ raw: lines.join("\n"),
207
+ sourcePath: path,
208
+ };
209
+ }
210
+
211
+ export const factoryAdapter: ToolAdapter = {
212
+ id: "factory",
213
+ name: "Factory",
214
+ versions: ["v1"],
215
+ detectVersion: detectExplicitVersion,
216
+ getDefaultPaths: () => ({
217
+ mcp: "~/.factory/mcp.json",
218
+ skills: ["~/.factory/skills", ".factory/skills"],
219
+ agents: ["~/.factory/droids", ".factory/droids"],
220
+ }),
221
+ parseMcp: (config) => parseFactoryMcp(config),
222
+ generateMcp: (canonical) => generateFactoryMcp(canonical),
223
+ parseSkills: async (skillsDir) => await parseSkillsDir(skillsDir),
224
+ agentFileExtension: ".md",
225
+ renderAgent: async (options) => await renderFactoryAgent(options),
226
+ parseManagedAgentFile: async (path) =>
227
+ await parseFactoryManagedAgentFile(path),
228
+ };
@@ -3,6 +3,7 @@ import { claudeDesktopAdapter } from "./claude-desktop";
3
3
  import { clawdbotAdapter } from "./clawdbot";
4
4
  import { codexAdapter } from "./codex";
5
5
  import { cursorAdapter } from "./cursor";
6
+ import { factoryAdapter } from "./factory";
6
7
  import { referenceAdapter } from "./reference";
7
8
  import type { ResolveVersionOptions, ToolAdapter } from "./types";
8
9
 
@@ -64,6 +65,7 @@ export async function resolveAdapterVersion(
64
65
  registerAdapter(referenceAdapter);
65
66
  registerAdapter(cursorAdapter);
66
67
  registerAdapter(codexAdapter);
68
+ registerAdapter(factoryAdapter);
67
69
  registerAdapter(claudeCliAdapter);
68
70
  registerAdapter(claudeDesktopAdapter);
69
71
  registerAdapter(clawdbotAdapter);
@@ -18,6 +18,21 @@ export interface CanonicalSkill {
18
18
  path?: string;
19
19
  }
20
20
 
21
+ export interface RenderManagedAgentOptions {
22
+ raw: string;
23
+ rootDir: string;
24
+ tool: string;
25
+ targetPath: string;
26
+ homeDir?: string;
27
+ projectRoot?: string;
28
+ }
29
+
30
+ export interface ParsedManagedAgentFile {
31
+ name: string;
32
+ raw: string;
33
+ sourcePath: string;
34
+ }
35
+
21
36
  export interface AdapterDefaultPaths {
22
37
  mcp?: string;
23
38
  skills?: string | string[];
@@ -34,6 +49,13 @@ export interface ToolAdapter {
34
49
  parseSkills?: (skillsDir: string) => Promise<CanonicalSkill[]>;
35
50
  generateMcp?: (canonical: CanonicalMcpConfig, version?: string) => unknown;
36
51
  generateSkillsDir?: (skills: CanonicalSkill[]) => Promise<void>;
52
+ agentFileExtension?: string;
53
+ renderAgent?: (
54
+ options: RenderManagedAgentOptions
55
+ ) => Promise<string> | string;
56
+ parseManagedAgentFile?: (
57
+ path: string
58
+ ) => Promise<ParsedManagedAgentFile | null>;
37
59
  getDefaultPaths?: () => AdapterDefaultPaths;
38
60
  }
39
61