facult 1.1.0 → 1.2.1

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/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { parseJsonLenient } from "./util/json";
5
5
 
6
6
  export interface FacultConfig {
@@ -94,6 +94,7 @@ function looksLikeFacultRoot(root: string): boolean {
94
94
  // Heuristic: treat as a facult store if it contains something we'd create.
95
95
  return (
96
96
  dirExists(join(root, "rules")) ||
97
+ dirExists(join(root, "instructions")) ||
97
98
  dirExists(join(root, "agents")) ||
98
99
  dirExists(join(root, "skills")) ||
99
100
  dirExists(join(root, "mcp")) ||
@@ -135,12 +136,108 @@ export function facultStateDir(home: string = defaultHomeDir()): string {
135
136
  return join(home, ".facult");
136
137
  }
137
138
 
138
- export function facultAiStateDir(home: string = defaultHomeDir()): string {
139
- return join(facultStateDir(home), "ai");
139
+ export function projectRootFromAiRoot(
140
+ rootDir: string,
141
+ home: string = defaultHomeDir()
142
+ ): string | null {
143
+ const resolved = resolve(rootDir);
144
+ if (resolved === resolve(join(home, ".ai"))) {
145
+ return null;
146
+ }
147
+ if (resolved === resolve(legacyPreferredRoot(home))) {
148
+ return null;
149
+ }
150
+ return resolved.endsWith("/.ai") ? dirname(resolved) : null;
151
+ }
152
+
153
+ export function projectSlugFromAiRoot(
154
+ rootDir: string,
155
+ home: string = defaultHomeDir()
156
+ ): string | null {
157
+ const projectRoot = projectRootFromAiRoot(rootDir, home);
158
+ if (!projectRoot) {
159
+ return null;
160
+ }
161
+ const base = basename(projectRoot).trim().toLowerCase();
162
+ const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
163
+ return slug || "project";
164
+ }
165
+
166
+ export function facultGeneratedStateDir(args?: {
167
+ home?: string;
168
+ rootDir?: string;
169
+ }): string {
170
+ const home = args?.home ?? defaultHomeDir();
171
+ const rootDir = args?.rootDir;
172
+ const projectRoot = rootDir ? projectRootFromAiRoot(rootDir, home) : null;
173
+ return projectRoot ? join(projectRoot, ".facult") : facultStateDir(home);
174
+ }
175
+
176
+ export function facultAiStateDir(
177
+ home: string = defaultHomeDir(),
178
+ rootDir?: string
179
+ ): string {
180
+ return join(facultGeneratedStateDir({ home, rootDir }), "ai");
181
+ }
182
+
183
+ export function facultAiIndexPath(
184
+ home: string = defaultHomeDir(),
185
+ rootDir?: string
186
+ ): string {
187
+ return join(facultAiStateDir(home, rootDir), "index.json");
188
+ }
189
+
190
+ export function facultAiGraphPath(
191
+ home: string = defaultHomeDir(),
192
+ rootDir?: string
193
+ ): string {
194
+ return join(facultAiStateDir(home, rootDir), "graph.json");
195
+ }
196
+
197
+ export function facultAiRuntimeScopeDir(
198
+ home: string = defaultHomeDir(),
199
+ rootDir?: string
200
+ ): string {
201
+ const slug = rootDir ? projectSlugFromAiRoot(rootDir, home) : null;
202
+ return slug
203
+ ? join(facultStateDir(home), "ai", "projects", slug)
204
+ : join(facultStateDir(home), "ai", "global");
205
+ }
206
+
207
+ export function facultAiJournalPath(
208
+ home: string = defaultHomeDir(),
209
+ rootDir?: string
210
+ ): string {
211
+ return join(
212
+ facultAiRuntimeScopeDir(home, rootDir),
213
+ "journal",
214
+ "events.jsonl"
215
+ );
216
+ }
217
+
218
+ export function facultAiWritebackQueuePath(
219
+ home: string = defaultHomeDir(),
220
+ rootDir?: string
221
+ ): string {
222
+ return join(
223
+ facultAiRuntimeScopeDir(home, rootDir),
224
+ "writeback",
225
+ "queue.jsonl"
226
+ );
140
227
  }
141
228
 
142
- export function facultAiIndexPath(home: string = defaultHomeDir()): string {
143
- return join(facultAiStateDir(home), "index.json");
229
+ export function facultAiProposalDir(
230
+ home: string = defaultHomeDir(),
231
+ rootDir?: string
232
+ ): string {
233
+ return join(facultAiRuntimeScopeDir(home, rootDir), "evolution", "proposals");
234
+ }
235
+
236
+ export function facultAiDraftDir(
237
+ home: string = defaultHomeDir(),
238
+ rootDir?: string
239
+ ): string {
240
+ return join(facultAiRuntimeScopeDir(home, rootDir), "evolution", "drafts");
144
241
  }
145
242
 
146
243
  export function facultConfigPath(home: string = defaultHomeDir()): string {
@@ -268,3 +365,38 @@ export function facultRootDir(home: string = defaultHomeDir()): string {
268
365
  }
269
366
  return preferred;
270
367
  }
368
+
369
+ export function findNearestProjectAiRoot(start: string): string | null {
370
+ let current = resolve(start);
371
+ while (true) {
372
+ const candidate = join(current, ".ai");
373
+ if (looksLikeFacultRoot(candidate)) {
374
+ return candidate;
375
+ }
376
+ const parent = dirname(current);
377
+ if (parent === current) {
378
+ return null;
379
+ }
380
+ current = parent;
381
+ }
382
+ }
383
+
384
+ export function facultContextRootDir(args?: {
385
+ home?: string;
386
+ cwd?: string;
387
+ }): string {
388
+ const home = args?.home ?? defaultHomeDir();
389
+ const envRoot = process.env.FACULT_ROOT_DIR?.trim();
390
+ if (envRoot) {
391
+ const resolved = resolvePath(envRoot, home);
392
+ return isSafePathString(resolved) ? resolved : join(home, ".ai");
393
+ }
394
+
395
+ const cwd = args?.cwd?.trim() || process.cwd();
396
+ const projectRoot = findNearestProjectAiRoot(cwd);
397
+ if (projectRoot) {
398
+ return projectRoot;
399
+ }
400
+
401
+ return facultRootDir(home);
402
+ }
package/src/query.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { homedir } from "node:os";
2
2
  import { ensureAiIndexPath } from "./ai-state";
3
+ import type { AssetScope, AssetSourceKind } from "./graph";
3
4
  import type {
4
5
  AgentEntry,
5
6
  FacultIndex,
7
+ InstructionEntry,
6
8
  McpEntry,
7
9
  SkillEntry,
8
10
  SnippetEntry,
9
11
  } from "./index-builder";
10
- import { facultAiIndexPath, facultRootDir } from "./paths";
12
+ import {
13
+ facultAiIndexPath,
14
+ facultContextRootDir,
15
+ facultRootDir,
16
+ } from "./paths";
11
17
  import { applyOrgTrustList } from "./trust-list";
12
18
 
13
19
  export interface QueryFilters {
@@ -23,6 +29,10 @@ export interface QueryFilters {
23
29
  tags?: string[];
24
30
  /** Full-text search query (case-insensitive). */
25
31
  text?: string;
32
+ /** Only include entries from a specific source layer. */
33
+ sourceKind?: AssetSourceKind;
34
+ /** Only include entries from a specific asset scope. */
35
+ scope?: AssetScope;
26
36
  }
27
37
 
28
38
  interface IndexEntry {
@@ -32,6 +42,18 @@ interface IndexEntry {
32
42
  enabledFor?: string[];
33
43
  trusted?: boolean;
34
44
  auditStatus?: string;
45
+ sourceKind?: AssetSourceKind;
46
+ scope?: AssetScope;
47
+ }
48
+
49
+ export interface CapabilityMatch {
50
+ kind: "skills" | "mcp" | "agents" | "snippets" | "instructions";
51
+ name: string;
52
+ path: string;
53
+ description?: string;
54
+ tags?: string[];
55
+ sourceKind?: string;
56
+ scope?: string;
35
57
  }
36
58
 
37
59
  const WHITESPACE_RE = /\s+/;
@@ -46,7 +68,7 @@ function matchesEnabledFor(entry: IndexEntry, tool?: string): boolean {
46
68
  }
47
69
  const enabledFor = entry.enabledFor;
48
70
  if (!Array.isArray(enabledFor)) {
49
- return false;
71
+ return true;
50
72
  }
51
73
  const target = normalizeText(tool);
52
74
  return enabledFor.some((t) => normalizeText(t) === target);
@@ -99,16 +121,40 @@ function matchesText(entry: IndexEntry, text?: string): boolean {
99
121
  return terms.every((term) => haystack.includes(term.toLowerCase()));
100
122
  }
101
123
 
124
+ function matchesSourceKind(
125
+ entry: IndexEntry,
126
+ sourceKind?: AssetSourceKind
127
+ ): boolean {
128
+ if (!sourceKind) {
129
+ return true;
130
+ }
131
+ return entry.sourceKind === sourceKind;
132
+ }
133
+
134
+ function matchesScope(entry: IndexEntry, scope?: AssetScope): boolean {
135
+ if (!scope) {
136
+ return true;
137
+ }
138
+ return entry.scope === scope;
139
+ }
140
+
102
141
  /** Return the canonical facult root directory. */
103
142
  export function facultRootDirPath(home: string = homedir()): string {
104
143
  return facultRootDir(home);
105
144
  }
106
145
 
146
+ export function facultContextRootDirPath(home: string = homedir()): string {
147
+ return facultContextRootDir({ home, cwd: process.cwd() });
148
+ }
149
+
107
150
  /**
108
151
  * Return the path to the facult index.json file.
109
152
  */
110
153
  export function facultIndexPath(home: string = homedir()): string {
111
- return facultAiIndexPath(home);
154
+ return facultAiIndexPath(
155
+ home,
156
+ facultContextRootDir({ home, cwd: process.cwd() })
157
+ );
112
158
  }
113
159
 
114
160
  /**
@@ -125,7 +171,9 @@ export async function loadIndex(opts?: {
125
171
  if (!resolvedHome) {
126
172
  throw new Error("HOME is not set.");
127
173
  }
128
- const rootDir = opts?.rootDir ?? facultRootDir(resolvedHome);
174
+ const rootDir =
175
+ opts?.rootDir ??
176
+ facultContextRootDir({ home: resolvedHome, cwd: process.cwd() });
129
177
  const { path: indexPath } = await ensureAiIndexPath({
130
178
  homeDir: resolvedHome,
131
179
  rootDir,
@@ -180,6 +228,87 @@ export function filterSnippets(
180
228
  return filterEntries(entries, filters);
181
229
  }
182
230
 
231
+ /**
232
+ * Filter instruction entries using query filters.
233
+ */
234
+ export function filterInstructions(
235
+ entries: Record<string, InstructionEntry>,
236
+ filters?: QueryFilters
237
+ ): InstructionEntry[] {
238
+ return filterEntries(entries, filters);
239
+ }
240
+
241
+ export function findCapabilities(
242
+ index: FacultIndex,
243
+ filters: Pick<QueryFilters, "text" | "sourceKind" | "scope">
244
+ ): CapabilityMatch[] {
245
+ const results: CapabilityMatch[] = [];
246
+
247
+ for (const entry of filterSkills(index.skills, filters)) {
248
+ results.push({
249
+ kind: "skills",
250
+ name: entry.name,
251
+ path: entry.path,
252
+ description: entry.description,
253
+ tags: entry.tags,
254
+ sourceKind: entry.sourceKind,
255
+ scope: entry.scope,
256
+ });
257
+ }
258
+
259
+ for (const entry of filterMcp(index.mcp?.servers ?? {}, filters)) {
260
+ results.push({
261
+ kind: "mcp",
262
+ name: entry.name,
263
+ path: entry.path,
264
+ sourceKind: entry.sourceKind,
265
+ scope: entry.scope,
266
+ });
267
+ }
268
+
269
+ for (const entry of filterAgents(index.agents ?? {}, filters)) {
270
+ results.push({
271
+ kind: "agents",
272
+ name: entry.name,
273
+ path: entry.path,
274
+ description: entry.description,
275
+ sourceKind: entry.sourceKind,
276
+ scope: entry.scope,
277
+ });
278
+ }
279
+
280
+ for (const entry of filterSnippets(index.snippets ?? {}, filters)) {
281
+ results.push({
282
+ kind: "snippets",
283
+ name: entry.name,
284
+ path: entry.path,
285
+ description: entry.description,
286
+ tags: entry.tags,
287
+ sourceKind: entry.sourceKind,
288
+ scope: entry.scope,
289
+ });
290
+ }
291
+
292
+ for (const entry of filterInstructions(index.instructions ?? {}, filters)) {
293
+ results.push({
294
+ kind: "instructions",
295
+ name: entry.name,
296
+ path: entry.path,
297
+ description: entry.description,
298
+ tags: entry.tags,
299
+ sourceKind: entry.sourceKind,
300
+ scope: entry.scope,
301
+ });
302
+ }
303
+
304
+ return results.sort((a, b) => {
305
+ if (a.kind !== b.kind) {
306
+ return a.kind.localeCompare(b.kind);
307
+ }
308
+ return a.name.localeCompare(b.name);
309
+ });
310
+ }
311
+
183
312
  function filterEntries<T extends IndexEntry>(
184
313
  entries: Record<string, T>,
185
314
  filters?: QueryFilters
@@ -189,6 +318,8 @@ function filterEntries<T extends IndexEntry>(
189
318
  .filter((entry) => matchesUntrusted(entry, filters?.untrusted))
190
319
  .filter((entry) => matchesFlagged(entry, filters?.flagged))
191
320
  .filter((entry) => matchesPending(entry, filters?.pending))
321
+ .filter((entry) => matchesSourceKind(entry, filters?.sourceKind))
322
+ .filter((entry) => matchesScope(entry, filters?.scope))
192
323
  .filter((entry) => matchesTags(entry, filters?.tags))
193
324
  .filter((entry) => matchesText(entry, filters?.text))
194
325
  .sort((a, b) => a.name.localeCompare(b.name));
package/src/remote.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, rm } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { buildIndex } from "./index-builder";
5
6
  import { facultRootDir } from "./paths";
6
7
  import {
@@ -320,6 +321,100 @@ function renderTemplate(text: string, values: Record<string, string>): string {
320
321
  return out;
321
322
  }
322
323
 
324
+ function builtinPackRoot(packName: string): string {
325
+ const here = dirname(fileURLToPath(import.meta.url));
326
+ return join(here, "..", "assets", "packs", packName);
327
+ }
328
+
329
+ async function pathExists(pathValue: string): Promise<boolean> {
330
+ try {
331
+ await Bun.file(pathValue).stat();
332
+ return true;
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ async function listFilesRecursive(rootDir: string): Promise<string[]> {
339
+ const out: string[] = [];
340
+ const stack = [rootDir];
341
+ while (stack.length) {
342
+ const current = stack.pop();
343
+ if (!current) {
344
+ continue;
345
+ }
346
+ const entries = await readdir(current, { withFileTypes: true }).catch(
347
+ () => [] as import("node:fs").Dirent[]
348
+ );
349
+ for (const entry of entries) {
350
+ const fullPath = join(current, entry.name);
351
+ if (entry.isDirectory()) {
352
+ stack.push(fullPath);
353
+ } else if (entry.isFile()) {
354
+ out.push(fullPath);
355
+ }
356
+ }
357
+ }
358
+ return out.sort();
359
+ }
360
+
361
+ async function scaffoldBuiltinProjectAiPack(args: {
362
+ cwd?: string;
363
+ homeDir?: string;
364
+ dryRun?: boolean;
365
+ force?: boolean;
366
+ }): Promise<InstallResult> {
367
+ const cwd = resolve(args.cwd ?? process.cwd());
368
+ const rootDir = join(cwd, ".ai");
369
+ const packRoot = builtinPackRoot("facult-operating-model");
370
+ const files = await listFilesRecursive(packRoot);
371
+ const changedPaths: string[] = [];
372
+
373
+ for (const sourcePath of files) {
374
+ const relPath = relative(packRoot, sourcePath);
375
+ if (!relPath || relPath.startsWith("..")) {
376
+ continue;
377
+ }
378
+ const targetPath = join(rootDir, relPath);
379
+ const exists = await pathExists(targetPath);
380
+ if (exists && !args.force) {
381
+ continue;
382
+ }
383
+ changedPaths.push(targetPath);
384
+ if (!args.dryRun) {
385
+ await mkdir(dirname(targetPath), { recursive: true });
386
+ await Bun.write(targetPath, await Bun.file(sourcePath).text());
387
+ }
388
+ }
389
+
390
+ const configPath = join(rootDir, "config.toml");
391
+ if (!(await pathExists(configPath)) || args.force) {
392
+ changedPaths.push(configPath);
393
+ if (!args.dryRun) {
394
+ await mkdir(dirname(configPath), { recursive: true });
395
+ await Bun.write(configPath, "version = 1\n");
396
+ }
397
+ }
398
+
399
+ if (!args.dryRun) {
400
+ await buildIndex({
401
+ homeDir: args.homeDir,
402
+ rootDir,
403
+ force: false,
404
+ });
405
+ }
406
+
407
+ return {
408
+ ref: `${BUILTIN_INDEX_NAME}:facult-operating-model`,
409
+ type: "skill",
410
+ installedAs: "project-ai",
411
+ path: rootDir,
412
+ sourceTrustLevel: "trusted",
413
+ dryRun: Boolean(args.dryRun),
414
+ changedPaths: uniqueSorted(changedPaths),
415
+ };
416
+ }
417
+
323
418
  function compareVersions(a: string, b: string): number {
324
419
  const aTokens = (a.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
325
420
  const bTokens = (b.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
@@ -1589,6 +1684,7 @@ Usage:
1589
1684
  facult templates init snippet <marker> [--force] [--dry-run]
1590
1685
  facult templates init agents [--force] [--dry-run]
1591
1686
  facult templates init claude [--force] [--dry-run]
1687
+ facult templates init project-ai [--force] [--dry-run]
1592
1688
 
1593
1689
  Notes:
1594
1690
  - Templates are powered by the builtin remote index (${BUILTIN_INDEX_NAME}).
@@ -1872,13 +1968,23 @@ export async function templatesCommand(
1872
1968
  }
1873
1969
  if (sub === "list") {
1874
1970
  const json = rest.includes("--json");
1875
- const rows = BUILTIN_MANIFEST.items.map((item) => ({
1876
- id: item.id,
1877
- type: item.type,
1878
- title: item.title ?? "",
1879
- description: item.description ?? "",
1880
- version: item.version ?? "",
1881
- }));
1971
+ const rows = [
1972
+ ...BUILTIN_MANIFEST.items.map((item) => ({
1973
+ id: item.id,
1974
+ type: item.type,
1975
+ title: item.title ?? "",
1976
+ description: item.description ?? "",
1977
+ version: item.version ?? "",
1978
+ })),
1979
+ {
1980
+ id: "project-ai",
1981
+ type: "pack",
1982
+ title: "Project AI Pack",
1983
+ description:
1984
+ "Seed a repo-local .ai with the built-in Facult operating-model pack.",
1985
+ version: "1.0.0",
1986
+ },
1987
+ ];
1882
1988
  if (json) {
1883
1989
  console.log(JSON.stringify(rows, null, 2));
1884
1990
  return;
@@ -1897,7 +2003,7 @@ export async function templatesCommand(
1897
2003
  const [kind, ...args] = rest;
1898
2004
  if (!kind) {
1899
2005
  console.error(
1900
- "templates init requires a kind (skill|mcp|snippet|agents|claude)"
2006
+ "templates init requires a kind (skill|mcp|snippet|agents|claude|project-ai)"
1901
2007
  );
1902
2008
  process.exitCode = 2;
1903
2009
  return;
@@ -1907,6 +2013,31 @@ export async function templatesCommand(
1907
2013
  const json = args.includes("--json");
1908
2014
  const positional = args.filter((a) => a && !a.startsWith("-"));
1909
2015
 
2016
+ if (kind === "project-ai") {
2017
+ try {
2018
+ const result = await scaffoldBuiltinProjectAiPack({
2019
+ cwd: ctx.cwd,
2020
+ homeDir: ctx.homeDir,
2021
+ dryRun,
2022
+ force,
2023
+ });
2024
+ if (json) {
2025
+ console.log(JSON.stringify(result, null, 2));
2026
+ return;
2027
+ }
2028
+ const action = dryRun ? "Would scaffold" : "Scaffolded";
2029
+ console.log(`${action} ${kind} template as ${result.installedAs}`);
2030
+ for (const path of result.changedPaths) {
2031
+ console.log(` - ${path}`);
2032
+ }
2033
+ return;
2034
+ } catch (err) {
2035
+ console.error(err instanceof Error ? err.message : String(err));
2036
+ process.exitCode = 1;
2037
+ return;
2038
+ }
2039
+ }
2040
+
1910
2041
  let ref = "";
1911
2042
  let as: string | undefined;
1912
2043
  if (kind === "skill") {
package/src/trust-list.ts CHANGED
@@ -226,6 +226,7 @@ export async function applyOrgTrustList(
226
226
  },
227
227
  agents: index.agents ?? {},
228
228
  snippets: index.snippets ?? {},
229
+ instructions: index.instructions ?? {},
229
230
  };
230
231
 
231
232
  return next;
package/src/trust.ts CHANGED
@@ -17,6 +17,7 @@ function ensureIndexStructure(index: FacultIndex): FacultIndex {
17
17
  mcp: index.mcp ?? { servers: {} },
18
18
  agents: index.agents ?? {},
19
19
  snippets: index.snippets ?? {},
20
+ instructions: index.instructions ?? {},
20
21
  };
21
22
  }
22
23