facult 2.7.1 → 2.7.2

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.2",
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
 
@@ -69,6 +69,12 @@ 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;
72
78
  }
73
79
 
74
80
  export interface SnippetEntry {
@@ -197,6 +203,53 @@ function extractIndexMeta(entry: unknown): {
197
203
  };
198
204
  }
199
205
 
206
+ function findPreviousEntryByCanonicalRef(
207
+ previous: Record<string, unknown> | undefined,
208
+ canonicalRef: string | undefined,
209
+ fallbackName: string
210
+ ): unknown {
211
+ if (!previous) {
212
+ return undefined;
213
+ }
214
+ if (typeof canonicalRef === "string") {
215
+ for (const value of Object.values(previous)) {
216
+ if (!isPlainObject(value)) {
217
+ continue;
218
+ }
219
+ if (value.canonicalRef === canonicalRef) {
220
+ return value;
221
+ }
222
+ }
223
+ }
224
+ const legacyFallback = previous[fallbackName];
225
+ if (
226
+ isPlainObject(legacyFallback) &&
227
+ typeof legacyFallback.canonicalRef !== "string"
228
+ ) {
229
+ return legacyFallback;
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ function findPreviousMcpEntry(
235
+ previous: Record<string, unknown> | undefined,
236
+ canonicalRef: string | undefined,
237
+ name: string
238
+ ): unknown {
239
+ if (!previous) {
240
+ return undefined;
241
+ }
242
+ const candidate = previous[name];
243
+ if (!isPlainObject(candidate)) {
244
+ return undefined;
245
+ }
246
+ return typeof candidate.canonicalRef !== "string" ||
247
+ (typeof canonicalRef === "string" &&
248
+ candidate.canonicalRef === canonicalRef)
249
+ ? candidate
250
+ : undefined;
251
+ }
252
+
200
253
  function stripQuotes(s: string): string {
201
254
  const t = s.trim();
202
255
  if (
@@ -489,8 +542,12 @@ async function indexSkills(
489
542
  const md = await Bun.file(skillMd).text();
490
543
  const { description, tags } = parseSkillMarkdown(md);
491
544
  const name = basename(d);
492
-
493
- const prev = previous?.[name];
545
+ const canonicalRef = canonicalRefForPath(source, "skills", d);
546
+ const prev = findPreviousEntryByCanonicalRef(
547
+ previous,
548
+ canonicalRef,
549
+ name
550
+ );
494
551
  const meta = extractIndexMeta(prev);
495
552
 
496
553
  out[name] = {
@@ -498,7 +555,7 @@ async function indexSkills(
498
555
  path: d,
499
556
  description,
500
557
  tags,
501
- canonicalRef: canonicalRefForPath(source, "skills", d),
558
+ canonicalRef,
502
559
  lastModifiedAt: await statIsoTime(skillMd),
503
560
  enabledFor: meta.enabledFor,
504
561
  trusted: meta.trusted ?? false,
@@ -550,12 +607,13 @@ async function indexMcpServers(
550
607
 
551
608
  const lm = await statIsoTime(mcpConfigPath);
552
609
  for (const name of Object.keys(serversObj).sort()) {
553
- const prev = previous?.[name];
610
+ const canonicalRef = canonicalRefForPath(source, "mcp", mcpConfigPath);
611
+ const prev = findPreviousMcpEntry(previous, canonicalRef, name);
554
612
  const meta = extractIndexMeta(prev);
555
613
  out[name] = {
556
614
  name,
557
615
  path: mcpConfigPath,
558
- canonicalRef: canonicalRefForPath(source, "mcp", mcpConfigPath),
616
+ canonicalRef,
559
617
  lastModifiedAt: lm,
560
618
  definition: serversObj[name],
561
619
  enabledFor: meta.enabledFor,
@@ -576,7 +634,8 @@ async function indexMcpServers(
576
634
 
577
635
  async function indexAgents(
578
636
  agentsDir: string,
579
- source: IndexedSource
637
+ source: IndexedSource,
638
+ previous?: Record<string, unknown>
580
639
  ): Promise<Record<string, AgentEntry>> {
581
640
  const out: Record<string, AgentEntry> = {};
582
641
  const files: string[] = [];
@@ -596,6 +655,9 @@ async function indexAgents(
596
655
  for (const p of files) {
597
656
  const name =
598
657
  basename(p) === "agent.toml" ? basename(dirname(p)) : basename(p);
658
+ const canonicalRef = canonicalRefForPath(source, "agents", p);
659
+ const prev = findPreviousEntryByCanonicalRef(previous, canonicalRef, name);
660
+ const meta = extractIndexMeta(prev);
599
661
  let description: string | undefined;
600
662
  try {
601
663
  const raw = await Bun.file(p).text();
@@ -611,8 +673,14 @@ async function indexAgents(
611
673
  name,
612
674
  path: p,
613
675
  description,
614
- canonicalRef: canonicalRefForPath(source, "agents", p),
676
+ canonicalRef,
615
677
  lastModifiedAt: await statIsoTime(p),
678
+ enabledFor: meta.enabledFor,
679
+ trusted: meta.trusted ?? false,
680
+ trustedAt: meta.trustedAt,
681
+ trustedBy: meta.trustedBy,
682
+ auditStatus: meta.auditStatus ?? "pending",
683
+ lastAuditAt: meta.lastAuditAt,
616
684
  ...entryScopeMeta(source),
617
685
  };
618
686
  }
@@ -782,6 +850,9 @@ async function indexSourceAssets(
782
850
  const prevSkills = isPlainObject(previousIndex?.skills)
783
851
  ? (previousIndex?.skills as Record<string, unknown>)
784
852
  : undefined;
853
+ const prevAgents = isPlainObject(previousIndex?.agents)
854
+ ? (previousIndex?.agents as Record<string, unknown>)
855
+ : undefined;
785
856
  const prevMcpMap =
786
857
  isPlainObject(previousIndex?.mcp) &&
787
858
  isPlainObject((previousIndex.mcp as Record<string, unknown>).servers)
@@ -795,7 +866,7 @@ async function indexSourceAssets(
795
866
  await Promise.all([
796
867
  indexSkills(skillsDir, source, prevSkills),
797
868
  indexMcpServers(canonicalMcpPath, source, prevMcpMap),
798
- indexAgents(agentsDir, source),
869
+ indexAgents(agentsDir, source, prevAgents),
799
870
  indexSnippets(snippetsDir, source),
800
871
  indexInstructions(instructionsDir, source),
801
872
  indexToolAssets(toolsDir, source),