facult 2.7.1 → 2.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.7.1",
3
+ "version": "2.7.3",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ai-state.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { copyFile, mkdir, stat } from "node:fs/promises";
1
+ import type { Dirent } from "node:fs";
2
+ import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import { buildIndex } from "./index-builder";
4
5
  import {
5
6
  facultAiGraphPath,
6
7
  facultAiIndexPath,
7
8
  legacyFacultStateDirForRoot,
9
+ preferredGlobalAiRoot,
10
+ projectRootFromAiRoot,
8
11
  } from "./paths";
9
12
 
10
13
  async function fileExists(path: string): Promise<boolean> {
@@ -15,6 +18,99 @@ async function fileExists(path: string): Promise<boolean> {
15
18
  }
16
19
  }
17
20
 
21
+ async function newestPathMtime(path: string): Promise<number> {
22
+ try {
23
+ const st = await stat(path);
24
+ if (st.isFile()) {
25
+ return st.mtimeMs;
26
+ }
27
+ if (!st.isDirectory()) {
28
+ return 0;
29
+ }
30
+ let newest = st.mtimeMs;
31
+ let entries: Dirent<string>[] = [];
32
+ try {
33
+ entries = await readdir(path, { withFileTypes: true, encoding: "utf8" });
34
+ } catch {
35
+ return newest;
36
+ }
37
+
38
+ for (const entry of entries) {
39
+ const child = join(path, entry.name);
40
+ if (entry.isFile()) {
41
+ try {
42
+ const childStat = await stat(child);
43
+ newest = Math.max(newest, childStat.mtimeMs);
44
+ } catch {
45
+ // ignore unreadable children
46
+ }
47
+ continue;
48
+ }
49
+ if (entry.isDirectory()) {
50
+ newest = Math.max(newest, await newestPathMtime(child));
51
+ }
52
+ }
53
+ return newest;
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ async function watchedPathMtime(path: string): Promise<number> {
60
+ const newest = await newestPathMtime(path);
61
+ if (newest > 0) {
62
+ return newest;
63
+ }
64
+ try {
65
+ return (await stat(dirname(path))).mtimeMs;
66
+ } catch {
67
+ return 0;
68
+ }
69
+ }
70
+
71
+ async function canonicalAssetsNewerThanIndex(args: {
72
+ homeDir: string;
73
+ rootDir: string;
74
+ indexPath: string;
75
+ }): Promise<boolean> {
76
+ let indexMtimeMs = 0;
77
+ try {
78
+ indexMtimeMs = (await stat(args.indexPath)).mtimeMs;
79
+ } catch {
80
+ return true;
81
+ }
82
+
83
+ const watchedRelPaths = [
84
+ "AGENTS.global.md",
85
+ "AGENTS.override.global.md",
86
+ "agents",
87
+ "config.toml",
88
+ "instructions",
89
+ "mcp",
90
+ "skills",
91
+ "snippets",
92
+ "tools",
93
+ ];
94
+ const watchedRoots = [args.rootDir];
95
+
96
+ if (projectRootFromAiRoot(args.rootDir, args.homeDir)) {
97
+ const globalRoot = preferredGlobalAiRoot(args.homeDir);
98
+ if (globalRoot !== args.rootDir) {
99
+ watchedRoots.push(globalRoot);
100
+ }
101
+ }
102
+
103
+ for (const root of watchedRoots) {
104
+ for (const rel of watchedRelPaths) {
105
+ if ((await watchedPathMtime(join(root, rel))) > indexMtimeMs) {
106
+ return true;
107
+ }
108
+ }
109
+ }
110
+
111
+ return false;
112
+ }
113
+
18
114
  export function legacyAiIndexPath(rootDir: string): string {
19
115
  return join(rootDir, "index.json");
20
116
  }
@@ -46,6 +142,21 @@ export async function ensureAiIndexPath(args: {
46
142
  }> {
47
143
  const generatedPath = facultAiIndexPath(args.homeDir, args.rootDir);
48
144
  if (await fileExists(generatedPath)) {
145
+ if (
146
+ args.repair !== false &&
147
+ (await canonicalAssetsNewerThanIndex({
148
+ homeDir: args.homeDir,
149
+ rootDir: args.rootDir,
150
+ indexPath: generatedPath,
151
+ }))
152
+ ) {
153
+ const { outputPath } = await buildIndex({
154
+ rootDir: args.rootDir,
155
+ homeDir: args.homeDir,
156
+ force: false,
157
+ });
158
+ return { path: outputPath, repaired: true, source: "rebuilt" };
159
+ }
49
160
  return { path: generatedPath, repaired: false, source: "generated" };
50
161
  }
51
162
 
@@ -100,6 +211,25 @@ export async function ensureAiGraphPath(args: {
100
211
  }> {
101
212
  const generatedPath = facultAiGraphPath(args.homeDir, args.rootDir);
102
213
  if (await fileExists(generatedPath)) {
214
+ const generatedIndexPath = facultAiIndexPath(args.homeDir, args.rootDir);
215
+ const freshnessAnchor = (await fileExists(generatedIndexPath))
216
+ ? generatedIndexPath
217
+ : generatedPath;
218
+ if (
219
+ args.repair !== false &&
220
+ (await canonicalAssetsNewerThanIndex({
221
+ homeDir: args.homeDir,
222
+ rootDir: args.rootDir,
223
+ indexPath: freshnessAnchor,
224
+ }))
225
+ ) {
226
+ const { graphPath } = await buildIndex({
227
+ rootDir: args.rootDir,
228
+ homeDir: args.homeDir,
229
+ force: false,
230
+ });
231
+ return { path: graphPath, rebuilt: true };
232
+ }
103
233
  return { path: generatedPath, rebuilt: false };
104
234
  }
105
235
 
package/src/ai.ts CHANGED
@@ -308,6 +308,7 @@ function mapGraphNodeKind(kind: GraphNodeKind): string {
308
308
  case "agent":
309
309
  case "skill":
310
310
  case "mcp":
311
+ case "automation":
311
312
  case "doc":
312
313
  case "rendered-target":
313
314
  return kind;
@@ -16,6 +16,7 @@ function ensureIndexStructure(index: FacultIndex): FacultIndex {
16
16
  skills: index.skills ?? {},
17
17
  mcp: index.mcp ?? { servers: {} },
18
18
  agents: index.agents ?? {},
19
+ automations: index.automations ?? {},
19
20
  snippets: index.snippets ?? {},
20
21
  instructions: index.instructions ?? {},
21
22
  };
@@ -36,6 +36,7 @@ function ensureIndexStructure(index: FacultIndex): FacultIndex {
36
36
  skills: index.skills ?? {},
37
37
  mcp: index.mcp ?? { servers: {} },
38
38
  agents: index.agents ?? {},
39
+ automations: index.automations ?? {},
39
40
  snippets: index.snippets ?? {},
40
41
  instructions: index.instructions ?? {},
41
42
  };
@@ -12,6 +12,7 @@ type QueryableGraphKind =
12
12
  | GraphNodeKind
13
13
  | "skills"
14
14
  | "agents"
15
+ | "automations"
15
16
  | "snippets"
16
17
  | "instructions"
17
18
  | "docs"
@@ -34,6 +35,8 @@ const KIND_ALIASES: Record<QueryableGraphKind, GraphNodeKind> = {
34
35
  skills: "skill",
35
36
  agent: "agent",
36
37
  agents: "agent",
38
+ automation: "automation",
39
+ automations: "automation",
37
40
  snippet: "snippet",
38
41
  snippets: "snippet",
39
42
  instruction: "instruction",
package/src/graph.ts CHANGED
@@ -11,6 +11,7 @@ export type GraphNodeKind =
11
11
  | "skill"
12
12
  | "mcp"
13
13
  | "agent"
14
+ | "automation"
14
15
  | "snippet"
15
16
  | "instruction"
16
17
  | "doc"
@@ -69,6 +69,18 @@ export interface AgentEntry {
69
69
  path: string;
70
70
  description?: string;
71
71
  lastModifiedAt?: string;
72
+ enabledFor?: string[];
73
+ trusted?: boolean;
74
+ trustedAt?: string;
75
+ trustedBy?: string;
76
+ auditStatus?: "pending" | "passed" | "flagged";
77
+ lastAuditAt?: string;
78
+ }
79
+
80
+ export interface AutomationEntry {
81
+ name: string;
82
+ path: string;
83
+ lastModifiedAt?: string;
72
84
  }
73
85
 
74
86
  export interface SnippetEntry {
@@ -96,6 +108,7 @@ interface ToolAssetEntry extends AssetEntryBase {
96
108
  export interface SkillEntry extends AssetEntryBase {}
97
109
  export interface McpEntry extends AssetEntryBase {}
98
110
  export interface AgentEntry extends AssetEntryBase {}
111
+ export interface AutomationEntry extends AssetEntryBase {}
99
112
  export interface SnippetEntry extends AssetEntryBase {}
100
113
  export interface InstructionEntry extends AssetEntryBase {}
101
114
 
@@ -105,6 +118,7 @@ export interface FacultIndex {
105
118
  skills: Record<string, SkillEntry>;
106
119
  mcp: { servers: Record<string, McpEntry> };
107
120
  agents: Record<string, AgentEntry>;
121
+ automations?: Record<string, AutomationEntry>;
108
122
  snippets: Record<string, SnippetEntry>;
109
123
  instructions: Record<string, InstructionEntry>;
110
124
  }
@@ -121,6 +135,7 @@ interface SourceAssets {
121
135
  skills: Record<string, SkillEntry>;
122
136
  mcpServers: Record<string, McpEntry>;
123
137
  agents: Record<string, AgentEntry>;
138
+ automations: Record<string, AutomationEntry>;
124
139
  snippets: Record<string, SnippetEntry>;
125
140
  instructions: Record<string, InstructionEntry>;
126
141
  toolConfigs: Record<string, ToolAssetEntry>;
@@ -197,6 +212,53 @@ function extractIndexMeta(entry: unknown): {
197
212
  };
198
213
  }
199
214
 
215
+ function findPreviousEntryByCanonicalRef(
216
+ previous: Record<string, unknown> | undefined,
217
+ canonicalRef: string | undefined,
218
+ fallbackName: string
219
+ ): unknown {
220
+ if (!previous) {
221
+ return undefined;
222
+ }
223
+ if (typeof canonicalRef === "string") {
224
+ for (const value of Object.values(previous)) {
225
+ if (!isPlainObject(value)) {
226
+ continue;
227
+ }
228
+ if (value.canonicalRef === canonicalRef) {
229
+ return value;
230
+ }
231
+ }
232
+ }
233
+ const legacyFallback = previous[fallbackName];
234
+ if (
235
+ isPlainObject(legacyFallback) &&
236
+ typeof legacyFallback.canonicalRef !== "string"
237
+ ) {
238
+ return legacyFallback;
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ function findPreviousMcpEntry(
244
+ previous: Record<string, unknown> | undefined,
245
+ canonicalRef: string | undefined,
246
+ name: string
247
+ ): unknown {
248
+ if (!previous) {
249
+ return undefined;
250
+ }
251
+ const candidate = previous[name];
252
+ if (!isPlainObject(candidate)) {
253
+ return undefined;
254
+ }
255
+ return typeof candidate.canonicalRef !== "string" ||
256
+ (typeof canonicalRef === "string" &&
257
+ candidate.canonicalRef === canonicalRef)
258
+ ? candidate
259
+ : undefined;
260
+ }
261
+
200
262
  function stripQuotes(s: string): string {
201
263
  const t = s.trim();
202
264
  if (
@@ -407,6 +469,7 @@ function canonicalRefForPath(
407
469
  category:
408
470
  | "skills"
409
471
  | "agents"
472
+ | "automations"
410
473
  | "snippets"
411
474
  | "instructions"
412
475
  | "mcp"
@@ -489,8 +552,12 @@ async function indexSkills(
489
552
  const md = await Bun.file(skillMd).text();
490
553
  const { description, tags } = parseSkillMarkdown(md);
491
554
  const name = basename(d);
492
-
493
- const prev = previous?.[name];
555
+ const canonicalRef = canonicalRefForPath(source, "skills", d);
556
+ const prev = findPreviousEntryByCanonicalRef(
557
+ previous,
558
+ canonicalRef,
559
+ name
560
+ );
494
561
  const meta = extractIndexMeta(prev);
495
562
 
496
563
  out[name] = {
@@ -498,7 +565,7 @@ async function indexSkills(
498
565
  path: d,
499
566
  description,
500
567
  tags,
501
- canonicalRef: canonicalRefForPath(source, "skills", d),
568
+ canonicalRef,
502
569
  lastModifiedAt: await statIsoTime(skillMd),
503
570
  enabledFor: meta.enabledFor,
504
571
  trusted: meta.trusted ?? false,
@@ -550,12 +617,13 @@ async function indexMcpServers(
550
617
 
551
618
  const lm = await statIsoTime(mcpConfigPath);
552
619
  for (const name of Object.keys(serversObj).sort()) {
553
- const prev = previous?.[name];
620
+ const canonicalRef = canonicalRefForPath(source, "mcp", mcpConfigPath);
621
+ const prev = findPreviousMcpEntry(previous, canonicalRef, name);
554
622
  const meta = extractIndexMeta(prev);
555
623
  out[name] = {
556
624
  name,
557
625
  path: mcpConfigPath,
558
- canonicalRef: canonicalRefForPath(source, "mcp", mcpConfigPath),
626
+ canonicalRef,
559
627
  lastModifiedAt: lm,
560
628
  definition: serversObj[name],
561
629
  enabledFor: meta.enabledFor,
@@ -576,7 +644,8 @@ async function indexMcpServers(
576
644
 
577
645
  async function indexAgents(
578
646
  agentsDir: string,
579
- source: IndexedSource
647
+ source: IndexedSource,
648
+ previous?: Record<string, unknown>
580
649
  ): Promise<Record<string, AgentEntry>> {
581
650
  const out: Record<string, AgentEntry> = {};
582
651
  const files: string[] = [];
@@ -596,6 +665,9 @@ async function indexAgents(
596
665
  for (const p of files) {
597
666
  const name =
598
667
  basename(p) === "agent.toml" ? basename(dirname(p)) : basename(p);
668
+ const canonicalRef = canonicalRefForPath(source, "agents", p);
669
+ const prev = findPreviousEntryByCanonicalRef(previous, canonicalRef, name);
670
+ const meta = extractIndexMeta(prev);
599
671
  let description: string | undefined;
600
672
  try {
601
673
  const raw = await Bun.file(p).text();
@@ -611,14 +683,53 @@ async function indexAgents(
611
683
  name,
612
684
  path: p,
613
685
  description,
614
- canonicalRef: canonicalRefForPath(source, "agents", p),
686
+ canonicalRef,
615
687
  lastModifiedAt: await statIsoTime(p),
688
+ enabledFor: meta.enabledFor,
689
+ trusted: meta.trusted ?? false,
690
+ trustedAt: meta.trustedAt,
691
+ trustedBy: meta.trustedBy,
692
+ auditStatus: meta.auditStatus ?? "pending",
693
+ lastAuditAt: meta.lastAuditAt,
616
694
  ...entryScopeMeta(source),
617
695
  };
618
696
  }
619
697
  return out;
620
698
  }
621
699
 
700
+ async function indexAutomations(
701
+ automationsDir: string,
702
+ source: IndexedSource
703
+ ): Promise<Record<string, AutomationEntry>> {
704
+ const out: Record<string, AutomationEntry> = {};
705
+ const dirs = await listSubdirs(automationsDir);
706
+ for (const d of dirs) {
707
+ const automationToml = join(d, "automation.toml");
708
+ try {
709
+ const st = await Bun.file(automationToml).stat();
710
+ if (!st.isFile()) {
711
+ continue;
712
+ }
713
+
714
+ const name = basename(d);
715
+ out[name] = {
716
+ name,
717
+ path: automationToml,
718
+ canonicalRef: canonicalRefForPath(
719
+ source,
720
+ "automations",
721
+ automationToml
722
+ ),
723
+ lastModifiedAt: await statIsoTime(automationToml),
724
+ ...entryScopeMeta(source),
725
+ };
726
+ } catch {
727
+ // Ignore malformed automation entries.
728
+ }
729
+ }
730
+ return out;
731
+ }
732
+
622
733
  async function indexSnippets(
623
734
  snippetsDir: string,
624
735
  source: IndexedSource
@@ -770,6 +881,7 @@ async function indexSourceAssets(
770
881
  ): Promise<SourceAssets> {
771
882
  const skillsDir = join(source.rootDir, "skills");
772
883
  const agentsDir = join(source.rootDir, "agents");
884
+ const automationsDir = join(source.rootDir, "automations");
773
885
  const snippetsDir = join(source.rootDir, "snippets");
774
886
  const instructionsDir = join(source.rootDir, "instructions");
775
887
  const toolsDir = join(source.rootDir, "tools");
@@ -782,6 +894,9 @@ async function indexSourceAssets(
782
894
  const prevSkills = isPlainObject(previousIndex?.skills)
783
895
  ? (previousIndex?.skills as Record<string, unknown>)
784
896
  : undefined;
897
+ const prevAgents = isPlainObject(previousIndex?.agents)
898
+ ? (previousIndex?.agents as Record<string, unknown>)
899
+ : undefined;
785
900
  const prevMcpMap =
786
901
  isPlainObject(previousIndex?.mcp) &&
787
902
  isPlainObject((previousIndex.mcp as Record<string, unknown>).servers)
@@ -791,20 +906,29 @@ async function indexSourceAssets(
791
906
  >)
792
907
  : undefined;
793
908
 
794
- const [skills, mcpServers, agents, snippets, instructions, toolAssets] =
795
- await Promise.all([
796
- indexSkills(skillsDir, source, prevSkills),
797
- indexMcpServers(canonicalMcpPath, source, prevMcpMap),
798
- indexAgents(agentsDir, source),
799
- indexSnippets(snippetsDir, source),
800
- indexInstructions(instructionsDir, source),
801
- indexToolAssets(toolsDir, source),
802
- ]);
909
+ const [
910
+ skills,
911
+ mcpServers,
912
+ agents,
913
+ automations,
914
+ snippets,
915
+ instructions,
916
+ toolAssets,
917
+ ] = await Promise.all([
918
+ indexSkills(skillsDir, source, prevSkills),
919
+ indexMcpServers(canonicalMcpPath, source, prevMcpMap),
920
+ indexAgents(agentsDir, source, prevAgents),
921
+ indexAutomations(automationsDir, source),
922
+ indexSnippets(snippetsDir, source),
923
+ indexInstructions(instructionsDir, source),
924
+ indexToolAssets(toolsDir, source),
925
+ ]);
803
926
 
804
927
  return {
805
928
  skills,
806
929
  mcpServers,
807
930
  agents,
931
+ automations,
808
932
  snippets,
809
933
  instructions,
810
934
  toolConfigs: toolAssets.toolConfigs,
@@ -836,6 +960,7 @@ function registerGraphEntries<
836
960
  | "skill"
837
961
  | "mcp"
838
962
  | "agent"
963
+ | "automation"
839
964
  | "snippet"
840
965
  | "instruction"
841
966
  | "doc"
@@ -935,6 +1060,11 @@ function buildActiveEntryMap(
935
1060
  for (const [name, entry] of Object.entries(sourceEntry.assets.agents)) {
936
1061
  active.set(activeEntryKey("agent", name), sourceIdentity(entry));
937
1062
  }
1063
+ for (const [name, entry] of Object.entries(
1064
+ sourceEntry.assets.automations
1065
+ )) {
1066
+ active.set(activeEntryKey("automation", name), sourceIdentity(entry));
1067
+ }
938
1068
  for (const [name, entry] of Object.entries(sourceEntry.assets.snippets)) {
939
1069
  active.set(activeEntryKey("snippet", name), sourceIdentity(entry));
940
1070
  }
@@ -1018,6 +1148,7 @@ async function addReferenceEdgesForEntries<
1018
1148
  kind:
1019
1149
  | "skill"
1020
1150
  | "agent"
1151
+ | "automation"
1021
1152
  | "snippet"
1022
1153
  | "instruction"
1023
1154
  | "doc"
@@ -1172,6 +1303,7 @@ function sourceNodeIdForEntry(args: {
1172
1303
  | "skill"
1173
1304
  | "mcp"
1174
1305
  | "agent"
1306
+ | "automation"
1175
1307
  | "snippet"
1176
1308
  | "instruction"
1177
1309
  | "doc"
@@ -1463,6 +1595,9 @@ export async function buildIndex(opts?: {
1463
1595
  sourceIndexes.map((entry) => entry.assets.mcpServers)
1464
1596
  );
1465
1597
  const agents = mergeByName(sourceIndexes.map((entry) => entry.assets.agents));
1598
+ const automations = mergeByName(
1599
+ sourceIndexes.map((entry) => entry.assets.automations)
1600
+ );
1466
1601
  const snippets = mergeByName(
1467
1602
  sourceIndexes.map((entry) => entry.assets.snippets)
1468
1603
  );
@@ -1476,6 +1611,7 @@ export async function buildIndex(opts?: {
1476
1611
  skills,
1477
1612
  mcp: { servers },
1478
1613
  agents,
1614
+ automations,
1479
1615
  snippets,
1480
1616
  instructions,
1481
1617
  };
@@ -1508,6 +1644,12 @@ export async function buildIndex(opts?: {
1508
1644
  "agent",
1509
1645
  activeSelections
1510
1646
  );
1647
+ registerGraphEntries(
1648
+ graph,
1649
+ sourceEntry.assets.automations,
1650
+ "automation",
1651
+ activeSelections
1652
+ );
1511
1653
  registerGraphEntries(
1512
1654
  graph,
1513
1655
  sourceEntry.assets.snippets,
@@ -1562,6 +1704,12 @@ export async function buildIndex(opts?: {
1562
1704
  "agent",
1563
1705
  refsByRoot
1564
1706
  );
1707
+ await addReferenceEdgesForEntries(
1708
+ graph,
1709
+ sourceEntry.assets.automations,
1710
+ "automation",
1711
+ refsByRoot
1712
+ );
1565
1713
  await addReferenceEdgesForEntries(
1566
1714
  graph,
1567
1715
  sourceEntry.assets.snippets,
package/src/trust-list.ts CHANGED
@@ -234,6 +234,7 @@ export async function applyOrgTrustList(
234
234
  }),
235
235
  },
236
236
  agents: index.agents ?? {},
237
+ automations: index.automations ?? {},
237
238
  snippets: index.snippets ?? {},
238
239
  instructions: index.instructions ?? {},
239
240
  };
package/src/trust.ts CHANGED
@@ -23,6 +23,7 @@ function ensureIndexStructure(index: FacultIndex): FacultIndex {
23
23
  skills: index.skills ?? {},
24
24
  mcp: index.mcp ?? { servers: {} },
25
25
  agents: index.agents ?? {},
26
+ automations: index.automations ?? {},
26
27
  snippets: index.snippets ?? {},
27
28
  instructions: index.instructions ?? {},
28
29
  };