facult 2.7.4 → 2.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -344,13 +344,14 @@ version = 1
344
344
  [project_sync.codex]
345
345
  skills = ["hack-cli", "hack-tickets"]
346
346
  agents = ["review-operator"]
347
+ automations = ["project-check"]
347
348
  mcp_servers = ["github"]
348
349
  global_docs = true
349
350
  tool_rules = true
350
351
  tool_config = true
351
352
  ```
352
353
 
353
- That policy applies to project-managed tool renders, including assets inherited from the merged global index. If you want a global skill inside a repo-local managed Codex output, name it explicitly here. `fclt doctor --repair` can materialize repo-local project assets into `config.local.toml` for already-managed project roots.
354
+ That policy applies to project-managed tool renders, including assets inherited from the merged global index. If you want a global skill or shared Codex automation inside project-managed output, name it explicitly here. `fclt doctor --repair` can materialize repo-local project assets into `config.local.toml` for already-managed project roots.
354
355
 
355
356
  ### Snippets
356
357
 
@@ -603,7 +604,7 @@ When Codex is in managed mode, canonical automation sources live under:
603
604
  - `~/.ai/automations/<name>/...` for global automation state
604
605
  - `<repo>/.ai/automations/<name>/...` for project-scoped canonical state
605
606
 
606
- 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.
607
+ Managed sync renders global 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. Project-scoped automation sources are default-deny; add their names to `[project_sync.codex].automations` before project managed sync can render them into that shared live store.
607
608
 
608
609
  Example project automation:
609
610
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.7.4",
3
+ "version": "2.7.7",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@ export const codexAdapter: ToolAdapter = {
12
12
  mcp: "~/.codex/mcp.json",
13
13
  skills: ["~/.agents/skills", "~/.codex/skills"],
14
14
  agents: "~/.codex/agents",
15
- config: "~/.config/openai/codex.json",
15
+ config: "~/.codex/config.toml",
16
16
  }),
17
17
  parseMcp: (config) => parseMcpConfig(config),
18
18
  generateMcp: (canonical) => generateMcpConfig(canonical, "mcpServers"),
package/src/ai.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
2
- import { basename, dirname, join } from "node:path";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
4
  import { ensureAiGraphPath } from "./ai-state";
4
5
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
5
6
  import type { AssetScope, GraphNodeKind } from "./graph";
@@ -242,7 +243,17 @@ function canonicalRefToPath(args: {
242
243
  return join(facultRootDir(args.homeDir), args.ref.slice("@ai/".length));
243
244
  }
244
245
  if (args.ref.startsWith("@project/")) {
245
- return join(args.rootDir, args.ref.slice("@project/".length));
246
+ const relPath = args.ref.slice("@project/".length);
247
+ const canonicalPath = join(args.rootDir, relPath);
248
+ const projectRoot = projectRootFromAiRoot(args.rootDir, args.homeDir);
249
+ if (projectRoot) {
250
+ const projectPath = join(projectRoot, relPath);
251
+ if (existsSync(canonicalPath)) {
252
+ return canonicalPath;
253
+ }
254
+ return existsSync(projectPath) ? projectPath : canonicalPath;
255
+ }
256
+ return canonicalPath;
246
257
  }
247
258
  return null;
248
259
  }
@@ -337,7 +348,27 @@ async function resolveAssetSelection(args: {
337
348
  });
338
349
  const node = resolveGraphNode(graph, args.asset);
339
350
  if (!node) {
340
- throw new Error(`Asset not found in graph: ${args.asset}`);
351
+ const projectRoot = projectRootFromAiRoot(args.rootDir, args.homeDir);
352
+ if (projectRoot) {
353
+ const resolvedPath = resolve(projectRoot, args.asset);
354
+ const relPath = relative(projectRoot, resolvedPath);
355
+ if (
356
+ relPath &&
357
+ !relPath.startsWith("..") &&
358
+ !relPath.includes("\0") &&
359
+ (await fileExists(resolvedPath))
360
+ ) {
361
+ const normalizedRef = relPath.replaceAll("\\", "/");
362
+ return {
363
+ assetRef: `@project/${normalizedRef}`,
364
+ assetId: `file:project:${normalizedRef}`,
365
+ assetType: "file",
366
+ };
367
+ }
368
+ }
369
+ throw new Error(
370
+ `Asset not found in graph: ${args.asset}. Run "fclt graph show <selector>" to check indexed assets, or use a project-relative file path that exists.`
371
+ );
341
372
  }
342
373
  return {
343
374
  assetRef: node.canonicalRef ?? node.id,
package/src/doctor.ts CHANGED
@@ -103,6 +103,51 @@ async function pathExists(pathValue: string): Promise<boolean> {
103
103
  }
104
104
  }
105
105
 
106
+ async function hasCanonicalSource(rootDir: string): Promise<boolean> {
107
+ const fileCandidates = [
108
+ "config.toml",
109
+ "config.local.toml",
110
+ "AGENTS.global.md",
111
+ "AGENTS.override.global.md",
112
+ ];
113
+ for (const relPath of fileCandidates) {
114
+ if (await pathExists(join(rootDir, relPath))) {
115
+ return true;
116
+ }
117
+ }
118
+
119
+ const dirCandidates = [
120
+ "agents",
121
+ "automations",
122
+ "instructions",
123
+ "mcp",
124
+ "rules",
125
+ "skills",
126
+ "snippets",
127
+ "tools",
128
+ ];
129
+ for (const relPath of dirCandidates) {
130
+ const entries = await readdir(join(rootDir, relPath)).catch(
131
+ () => [] as string[]
132
+ );
133
+ if (entries.some((entry) => !entry.startsWith("."))) {
134
+ return true;
135
+ }
136
+ }
137
+
138
+ return false;
139
+ }
140
+
141
+ async function isGeneratedOnlyProjectRoot(args: {
142
+ home: string;
143
+ rootDir: string;
144
+ }): Promise<boolean> {
145
+ if (projectRootFromAiRoot(args.rootDir, args.home) == null) {
146
+ return false;
147
+ }
148
+ return !(await hasCanonicalSource(args.rootDir));
149
+ }
150
+
106
151
  async function hashFile(pathValue: string): Promise<string> {
107
152
  const data = await readFile(pathValue);
108
153
  return createHash("sha256").update(data).digest("hex");
@@ -344,6 +389,23 @@ async function listProjectAgentNames(rootDir: string): Promise<string[]> {
344
389
  .sort((a, b) => a.localeCompare(b));
345
390
  }
346
391
 
392
+ async function listProjectAutomationNames(rootDir: string): Promise<string[]> {
393
+ const automationsDir = join(rootDir, "automations");
394
+ const entries = await readdir(automationsDir, { withFileTypes: true }).catch(
395
+ () => [] as import("node:fs").Dirent[]
396
+ );
397
+ const names: string[] = [];
398
+ for (const entry of entries) {
399
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
400
+ continue;
401
+ }
402
+ if (await pathExists(join(automationsDir, entry.name, "automation.toml"))) {
403
+ names.push(entry.name);
404
+ }
405
+ }
406
+ return names.sort((a, b) => a.localeCompare(b));
407
+ }
408
+
347
409
  async function listProjectMcpNames(rootDir: string): Promise<string[]> {
348
410
  const trackedPaths = [
349
411
  join(rootDir, "mcp", "servers.json"),
@@ -404,6 +466,7 @@ async function planProjectSyncPolicyRepair(args: {
404
466
  {
405
467
  skills?: string[];
406
468
  agents?: string[];
469
+ automations?: string[];
407
470
  mcpServers?: string[];
408
471
  globalDocs?: boolean;
409
472
  toolRules?: boolean;
@@ -426,18 +489,21 @@ async function planProjectSyncPolicyRepair(args: {
426
489
  const configuredTools = new Set(
427
490
  await loadConfiguredProjectSyncTools({ rootDir: args.rootDir })
428
491
  );
429
- const [skills, agents, mcpServers, globalDocs] = await Promise.all([
430
- listProjectSkillNames(args.rootDir),
431
- listProjectAgentNames(args.rootDir),
432
- listProjectMcpNames(args.rootDir),
433
- hasProjectGlobalDocs(args.rootDir),
434
- ]);
492
+ const [skills, agents, automations, mcpServers, globalDocs] =
493
+ await Promise.all([
494
+ listProjectSkillNames(args.rootDir),
495
+ listProjectAgentNames(args.rootDir),
496
+ listProjectAutomationNames(args.rootDir),
497
+ listProjectMcpNames(args.rootDir),
498
+ hasProjectGlobalDocs(args.rootDir),
499
+ ]);
435
500
 
436
501
  const toolPolicies: Record<
437
502
  string,
438
503
  {
439
504
  skills?: string[];
440
505
  agents?: string[];
506
+ automations?: string[];
441
507
  mcpServers?: string[];
442
508
  globalDocs?: boolean;
443
509
  toolRules?: boolean;
@@ -457,6 +523,7 @@ async function planProjectSyncPolicyRepair(args: {
457
523
  if (
458
524
  skills.length === 0 &&
459
525
  agents.length === 0 &&
526
+ automations.length === 0 &&
460
527
  mcpServers.length === 0 &&
461
528
  !globalDocs &&
462
529
  !toolRules &&
@@ -468,6 +535,7 @@ async function planProjectSyncPolicyRepair(args: {
468
535
  toolPolicies[tool] = {
469
536
  ...(skills.length > 0 ? { skills } : {}),
470
537
  ...(agents.length > 0 ? { agents } : {}),
538
+ ...(automations.length > 0 ? { automations } : {}),
471
539
  ...(mcpServers.length > 0 ? { mcpServers } : {}),
472
540
  ...(globalDocs ? { globalDocs: true } : {}),
473
541
  ...(toolRules ? { toolRules: true } : {}),
@@ -627,6 +695,13 @@ export async function doctorCommand(argv: string[]) {
627
695
  `Project sync is still implicit for managed tools (${projectSyncRepairTools.join(", ")}). Run \`fclt doctor --repair\` to write explicit [project_sync.<tool>] entries.`
628
696
  );
629
697
  }
698
+ if (await isGeneratedOnlyProjectRoot({ home, rootDir })) {
699
+ console.log(
700
+ "Project .ai root contains generated state only. Canonical project source is missing, so managed project sync should be treated as unsafe until source is initialized, restored, or management is detached."
701
+ );
702
+ process.exitCode = 1;
703
+ return;
704
+ }
630
705
 
631
706
  if (result.source === "generated") {
632
707
  console.log("AI index is healthy.");
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./graph-query";
26
26
  import type {
27
27
  AgentEntry,
28
+ AutomationEntry,
28
29
  FacultIndex,
29
30
  InstructionEntry,
30
31
  McpEntry,
@@ -43,12 +44,19 @@ import {
43
44
  } from "./query";
44
45
  import { parseJsonLenient } from "./util/json";
45
46
 
46
- type ListKind = "skills" | "mcp" | "agents" | "snippets" | "instructions";
47
+ type ListKind =
48
+ | "skills"
49
+ | "mcp"
50
+ | "agents"
51
+ | "automations"
52
+ | "snippets"
53
+ | "instructions";
47
54
 
48
55
  const LIST_KINDS: ListKind[] = [
49
56
  "skills",
50
57
  "mcp",
51
58
  "agents",
59
+ "automations",
52
60
  "snippets",
53
61
  "instructions",
54
62
  ];
@@ -65,6 +73,7 @@ export interface FindCommandOptions {
65
73
  }
66
74
 
67
75
  type GraphCommandKind = "show" | "deps" | "dependents";
76
+ type ShowKind = ListKind | "mcp";
68
77
 
69
78
  interface ContextualCommandOptions {
70
79
  rootArg?: string;
@@ -177,7 +186,7 @@ function printListHelp() {
177
186
  title: "Usage",
178
187
  lines: renderBullets([
179
188
  renderCode(
180
- "fclt list [skills|mcp|agents|snippets|instructions] [options]"
189
+ "fclt list [skills|mcp|agents|automations|snippets|instructions] [options]"
181
190
  ),
182
191
  renderCode("fclt list"),
183
192
  ]),
@@ -494,6 +503,32 @@ function auditBadge(status?: string): string {
494
503
  return renderBadge("audit pending", "warn");
495
504
  }
496
505
 
506
+ function showKindForToken(token: string): ShowKind | null {
507
+ switch (token) {
508
+ case "agent":
509
+ case "agents":
510
+ return "agents";
511
+ case "automation":
512
+ case "automations":
513
+ return "automations";
514
+ case "instruction":
515
+ case "instructions":
516
+ return "instructions";
517
+ case "mcp":
518
+ case "mcp-server":
519
+ case "mcp-servers":
520
+ return "mcp";
521
+ case "skill":
522
+ case "skills":
523
+ return "skills";
524
+ case "snippet":
525
+ case "snippets":
526
+ return "snippets";
527
+ default:
528
+ return null;
529
+ }
530
+ }
531
+
497
532
  function displayDescription(value?: string): string {
498
533
  const normalized = value
499
534
  ?.trim()
@@ -571,6 +606,7 @@ async function listCommand(argv: string[]) {
571
606
  | SkillEntry[]
572
607
  | McpEntry[]
573
608
  | AgentEntry[]
609
+ | AutomationEntry[]
574
610
  | SnippetEntry[]
575
611
  | InstructionEntry[] = [];
576
612
 
@@ -584,6 +620,20 @@ async function listCommand(argv: string[]) {
584
620
  case "agents":
585
621
  entries = filterAgents(index.agents ?? {}, opts.filters);
586
622
  break;
623
+ case "automations":
624
+ entries = Object.values(index.automations ?? {}).filter((entry) => {
625
+ if (
626
+ opts.filters.sourceKind &&
627
+ entry.sourceKind !== opts.filters.sourceKind
628
+ ) {
629
+ return false;
630
+ }
631
+ if (opts.filters.scope && entry.scope !== opts.filters.scope) {
632
+ return false;
633
+ }
634
+ return true;
635
+ });
636
+ break;
587
637
  case "snippets":
588
638
  entries = filterSnippets(index.snippets ?? {}, opts.filters);
589
639
  break;
@@ -646,11 +696,17 @@ async function listCommand(argv: string[]) {
646
696
  };
647
697
  }
648
698
 
649
- const detailEntry = entry as AgentEntry | SnippetEntry | InstructionEntry;
699
+ const detailEntry = entry as
700
+ | AgentEntry
701
+ | AutomationEntry
702
+ | SnippetEntry
703
+ | InstructionEntry;
650
704
  return {
651
705
  title: entry.name,
652
706
  meta: sourceLabel(entry),
653
- description: displayDescription(detailEntry.description),
707
+ description: displayDescription(
708
+ "description" in detailEntry ? detailEntry.description : undefined
709
+ ),
654
710
  };
655
711
  });
656
712
 
@@ -834,45 +890,59 @@ async function showCommand(argv: string[]) {
834
890
  return;
835
891
  }
836
892
 
893
+ const rootDir = resolveCliContextRoot({
894
+ rootArg: context.rootArg,
895
+ scope: context.scopeMode,
896
+ cwd: process.cwd(),
897
+ });
898
+
837
899
  let index: FacultIndex;
838
900
  try {
839
- index = await loadIndex({
840
- rootDir: resolveCliContextRoot({
841
- rootArg: context.rootArg,
842
- scope: context.scopeMode,
843
- cwd: process.cwd(),
844
- }),
845
- });
901
+ index = await loadIndex({ rootDir });
846
902
  } catch (err) {
847
903
  console.error(err instanceof Error ? err.message : String(err));
848
904
  process.exitCode = 1;
849
905
  return;
850
906
  }
851
907
 
852
- let kind: ListKind | "mcp" = "skills";
908
+ let kind: ShowKind = "skills";
853
909
  let name = raw;
910
+ const colonIndex = raw.indexOf(":");
911
+ if (colonIndex > 0) {
912
+ const tokenKind = showKindForToken(raw.slice(0, colonIndex));
913
+ if (tokenKind) {
914
+ kind = tokenKind;
915
+ name = raw.slice(colonIndex + 1);
916
+ }
917
+ }
854
918
 
855
- if (raw.startsWith("mcp:")) {
856
- kind = "mcp";
857
- name = raw.slice("mcp:".length);
858
- } else if (raw.startsWith("instruction:")) {
859
- kind = "instructions";
860
- name = raw.slice("instruction:".length);
861
- } else if (raw.startsWith("instructions:")) {
862
- kind = "instructions";
863
- name = raw.slice("instructions:".length);
919
+ try {
920
+ const graph = await loadGraph({ rootDir });
921
+ const node = resolveGraphNode(graph, raw, {
922
+ sourceKind: context.sourceKind,
923
+ scope: scopeFilterForMode(context.scopeMode),
924
+ });
925
+ const graphKind = node ? showKindForToken(node.kind) : null;
926
+ if (node && graphKind) {
927
+ kind = graphKind;
928
+ name = node.name;
929
+ }
930
+ } catch {
931
+ // A missing or stale graph should not make basic index-backed show fail.
864
932
  }
865
933
 
866
934
  let entry:
867
935
  | SkillEntry
868
936
  | McpEntry
869
937
  | AgentEntry
938
+ | AutomationEntry
870
939
  | SnippetEntry
871
940
  | InstructionEntry
872
941
  | null = null;
873
942
  const skill = index.skills[name];
874
943
  const mcpServer = index.mcp?.servers?.[name];
875
944
  const agent = index.agents?.[name];
945
+ const automation = index.automations?.[name];
876
946
  const snippet = index.snippets?.[name];
877
947
  const instruction = index.instructions?.[name];
878
948
  const matchesContext = (candidate: {
@@ -896,6 +966,15 @@ async function showCommand(argv: string[]) {
896
966
  } else if (kind === "skills" && agent && matchesContext(agent)) {
897
967
  kind = "agents";
898
968
  entry = agent;
969
+ } else if (
970
+ kind === "automations" &&
971
+ automation &&
972
+ matchesContext(automation)
973
+ ) {
974
+ entry = automation;
975
+ } else if (kind === "skills" && automation && matchesContext(automation)) {
976
+ kind = "automations";
977
+ entry = automation;
899
978
  } else if (kind === "skills" && snippet && matchesContext(snippet)) {
900
979
  kind = "snippets";
901
980
  entry = snippet;
@@ -1105,6 +1184,7 @@ async function graphCommand(argv: string[]) {
1105
1184
  }
1106
1185
 
1107
1186
  async function adaptersCommand(argv: string[]) {
1187
+ const json = argv.includes("--json");
1108
1188
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
1109
1189
  console.log(
1110
1190
  renderPage({
@@ -1122,6 +1202,21 @@ async function adaptersCommand(argv: string[]) {
1122
1202
  }
1123
1203
  const { getAllAdapters } = await import("./adapters");
1124
1204
  const adapters = getAllAdapters();
1205
+ if (json) {
1206
+ console.log(
1207
+ JSON.stringify(
1208
+ adapters.map((adapter) => ({
1209
+ id: adapter.id,
1210
+ name: adapter.name,
1211
+ versions: adapter.versions,
1212
+ defaultPaths: adapter.getDefaultPaths?.() ?? {},
1213
+ })),
1214
+ null,
1215
+ 2
1216
+ )
1217
+ );
1218
+ return;
1219
+ }
1125
1220
  if (!adapters.length) {
1126
1221
  console.log(
1127
1222
  renderPage({