facult 2.8.1 → 2.8.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/README.md CHANGED
@@ -295,10 +295,11 @@ Typical layout:
295
295
 
296
296
  Important split:
297
297
  - `.ai/` is canonical source
298
- - `.ai/.facult/ai/` is generated AI state that belongs with the canonical root
299
- - machine-local Facult state such as managed-tool state, autosync runtime/config, install metadata, and launcher caches lives outside `.ai/`
298
+ - global `.ai/.facult/ai/` is generated AI state for the global canonical root
299
+ - project generated AI state lives in machine-local per-project Facult state, outside the repo
300
+ - machine-local Facult state such as project indexes, project graphs, managed-tool state, autosync runtime/config, install metadata, and launcher caches lives outside project `.ai/`
300
301
  - tool homes such as `.codex/` and `.claude/` are rendered outputs
301
- - the generated capability graph lives at `.ai/.facult/ai/graph.json`
302
+ - the generated capability graph lives under the active generated AI state directory
302
303
 
303
304
  ### Asset types
304
305
 
@@ -442,11 +443,11 @@ fclt ai evolve promote EV-00003 --to global --project
442
443
 
443
444
  Runtime state stays generated and local inside the active canonical root:
444
445
  - global writeback state: `~/.ai/.facult/ai/global/...`
445
- - project writeback state: `<repo>/.ai/.facult/ai/project/...`
446
+ - project writeback state: machine-local per-project Facult state under `.../projects/<slug-hash>/ai/project/...`
446
447
 
447
448
  That split is intentional:
448
449
  - canonical source remains in `~/.ai` or `<repo>/.ai`
449
- - writeback queues, journals, proposal records, trust state, autosync state, and other generated runtime/config state stay inside `.ai/.facult/`
450
+ - global generated state stays inside `~/.ai/.facult/`; project generated state stays outside the repo in machine-local state
450
451
  - those records let agents inspect what changed, why it changed, and how it was reviewed
451
452
 
452
453
  Use writeback when:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.8.1",
3
+ "version": "2.8.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
@@ -5,6 +5,7 @@ import { buildIndex } from "./index-builder";
5
5
  import {
6
6
  facultAiGraphPath,
7
7
  facultAiIndexPath,
8
+ legacyFacultAiStateDirs,
8
9
  legacyFacultStateDirForRoot,
9
10
  preferredGlobalAiRoot,
10
11
  projectRootFromAiRoot,
@@ -123,6 +124,21 @@ function legacyGeneratedAiIndexPath(homeDir: string, rootDir: string): string {
123
124
  );
124
125
  }
125
126
 
127
+ function legacyGeneratedAiIndexPaths(
128
+ homeDir: string,
129
+ rootDir: string
130
+ ): string[] {
131
+ return [
132
+ ...new Set([
133
+ ...legacyFacultAiStateDirs(homeDir, rootDir).map((dir) =>
134
+ join(dir, "index.json")
135
+ ),
136
+ legacyGeneratedAiIndexPath(homeDir, rootDir),
137
+ legacyAiIndexPath(rootDir),
138
+ ]),
139
+ ];
140
+ }
141
+
126
142
  function legacyGeneratedAiGraphPath(homeDir: string, rootDir: string): string {
127
143
  return join(
128
144
  legacyFacultStateDirForRoot(rootDir, homeDir),
@@ -131,6 +147,20 @@ function legacyGeneratedAiGraphPath(homeDir: string, rootDir: string): string {
131
147
  );
132
148
  }
133
149
 
150
+ function legacyGeneratedAiGraphPaths(
151
+ homeDir: string,
152
+ rootDir: string
153
+ ): string[] {
154
+ return [
155
+ ...new Set([
156
+ ...legacyFacultAiStateDirs(homeDir, rootDir).map((dir) =>
157
+ join(dir, "graph.json")
158
+ ),
159
+ legacyGeneratedAiGraphPath(homeDir, rootDir),
160
+ ]),
161
+ ];
162
+ }
163
+
134
164
  export async function ensureAiIndexPath(args: {
135
165
  homeDir: string;
136
166
  rootDir: string;
@@ -160,33 +190,35 @@ export async function ensureAiIndexPath(args: {
160
190
  return { path: generatedPath, repaired: false, source: "generated" };
161
191
  }
162
192
 
163
- const legacyGeneratedPath = legacyGeneratedAiIndexPath(
193
+ for (const legacyPath of legacyGeneratedAiIndexPaths(
164
194
  args.homeDir,
165
195
  args.rootDir
166
- );
167
- if (await fileExists(legacyGeneratedPath)) {
168
- if (args.repair !== false) {
169
- await mkdir(dirname(generatedPath), { recursive: true });
170
- await copyFile(legacyGeneratedPath, generatedPath);
171
- }
172
- return {
173
- path: generatedPath,
174
- repaired: args.repair !== false,
175
- source: "legacy",
176
- };
177
- }
178
-
179
- const legacyPath = legacyAiIndexPath(args.rootDir);
180
- if (await fileExists(legacyPath)) {
181
- if (args.repair !== false) {
182
- await mkdir(dirname(generatedPath), { recursive: true });
183
- await copyFile(legacyPath, generatedPath);
196
+ )) {
197
+ if (await fileExists(legacyPath)) {
198
+ if (args.repair !== false) {
199
+ if (
200
+ await canonicalAssetsNewerThanIndex({
201
+ homeDir: args.homeDir,
202
+ rootDir: args.rootDir,
203
+ indexPath: legacyPath,
204
+ })
205
+ ) {
206
+ const { outputPath } = await buildIndex({
207
+ rootDir: args.rootDir,
208
+ homeDir: args.homeDir,
209
+ force: false,
210
+ });
211
+ return { path: outputPath, repaired: true, source: "rebuilt" };
212
+ }
213
+ await mkdir(dirname(generatedPath), { recursive: true });
214
+ await copyFile(legacyPath, generatedPath);
215
+ }
216
+ return {
217
+ path: generatedPath,
218
+ repaired: args.repair !== false,
219
+ source: "legacy",
220
+ };
184
221
  }
185
- return {
186
- path: generatedPath,
187
- repaired: args.repair !== false,
188
- source: "legacy",
189
- };
190
222
  }
191
223
 
192
224
  if (args.repair !== false) {
@@ -233,16 +265,31 @@ export async function ensureAiGraphPath(args: {
233
265
  return { path: generatedPath, rebuilt: false };
234
266
  }
235
267
 
236
- const legacyGeneratedPath = legacyGeneratedAiGraphPath(
268
+ for (const legacyGeneratedPath of legacyGeneratedAiGraphPaths(
237
269
  args.homeDir,
238
270
  args.rootDir
239
- );
240
- if (await fileExists(legacyGeneratedPath)) {
241
- if (args.repair !== false) {
242
- await mkdir(dirname(generatedPath), { recursive: true });
243
- await copyFile(legacyGeneratedPath, generatedPath);
271
+ )) {
272
+ if (await fileExists(legacyGeneratedPath)) {
273
+ if (args.repair !== false) {
274
+ if (
275
+ await canonicalAssetsNewerThanIndex({
276
+ homeDir: args.homeDir,
277
+ rootDir: args.rootDir,
278
+ indexPath: legacyGeneratedPath,
279
+ })
280
+ ) {
281
+ const { graphPath } = await buildIndex({
282
+ rootDir: args.rootDir,
283
+ homeDir: args.homeDir,
284
+ force: false,
285
+ });
286
+ return { path: graphPath, rebuilt: true };
287
+ }
288
+ await mkdir(dirname(generatedPath), { recursive: true });
289
+ await copyFile(legacyGeneratedPath, generatedPath);
290
+ }
291
+ return { path: generatedPath, rebuilt: args.repair !== false };
244
292
  }
245
- return { path: generatedPath, rebuilt: args.repair !== false };
246
293
  }
247
294
 
248
295
  if (args.repair !== false) {
package/src/ai.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  facultAiProposalDir,
12
12
  facultAiWritebackQueuePath,
13
13
  facultRootDir,
14
+ legacyFacultAiStateDirs,
14
15
  projectRootFromAiRoot,
15
16
  projectSlugFromAiRoot,
16
17
  } from "./paths";
@@ -218,6 +219,61 @@ async function readJsonLines<T>(pathValue: string): Promise<T[]> {
218
219
  .map((line) => JSON.parse(line) as T);
219
220
  }
220
221
 
222
+ async function readJsonLinesFromPaths<T>(pathValues: string[]): Promise<T[]> {
223
+ const entries: T[] = [];
224
+ for (const pathValue of pathValues) {
225
+ entries.push(...(await readJsonLines<T>(pathValue)));
226
+ }
227
+ return entries;
228
+ }
229
+
230
+ function aiRuntimeScopeName(rootDir: string, homeDir: string): AssetScope {
231
+ return projectRootFromAiRoot(rootDir, homeDir) ? "project" : "global";
232
+ }
233
+
234
+ function legacyAiRuntimeScopeDirs(homeDir: string, rootDir: string): string[] {
235
+ const scope = aiRuntimeScopeName(rootDir, homeDir);
236
+ return legacyFacultAiStateDirs(homeDir, rootDir).map((dir) =>
237
+ join(dir, scope)
238
+ );
239
+ }
240
+
241
+ function aiWritebackQueueReadPaths(homeDir: string, rootDir: string): string[] {
242
+ return [
243
+ ...legacyAiRuntimeScopeDirs(homeDir, rootDir).map((dir) =>
244
+ join(dir, "writeback", "queue.jsonl")
245
+ ),
246
+ facultAiWritebackQueuePath(homeDir, rootDir),
247
+ ];
248
+ }
249
+
250
+ function aiJournalReadPaths(homeDir: string, rootDir: string): string[] {
251
+ return [
252
+ ...legacyAiRuntimeScopeDirs(homeDir, rootDir).map((dir) =>
253
+ join(dir, "journal", "events.jsonl")
254
+ ),
255
+ facultAiJournalPath(homeDir, rootDir),
256
+ ];
257
+ }
258
+
259
+ function aiProposalReadDirs(homeDir: string, rootDir: string): string[] {
260
+ return uniqueStrings([
261
+ facultAiProposalDir(homeDir, rootDir),
262
+ ...legacyAiRuntimeScopeDirs(homeDir, rootDir).map((dir) =>
263
+ join(dir, "evolution", "proposals")
264
+ ),
265
+ ]);
266
+ }
267
+
268
+ async function firstExistingFile(paths: string[]): Promise<string | null> {
269
+ for (const pathValue of paths) {
270
+ if (await fileExists(pathValue)) {
271
+ return pathValue;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
221
277
  function supportedDraftTarget(pathValue: string): boolean {
222
278
  return pathValue.toLowerCase().endsWith(".md");
223
279
  }
@@ -285,8 +341,8 @@ async function latestWritebackMap(args: {
285
341
  homeDir: string;
286
342
  rootDir: string;
287
343
  }): Promise<Map<string, AiWritebackRecord>> {
288
- const entries = await readJsonLines<AiWritebackRecord>(
289
- facultAiWritebackQueuePath(args.homeDir, args.rootDir)
344
+ const entries = await readJsonLinesFromPaths<AiWritebackRecord>(
345
+ aiWritebackQueueReadPaths(args.homeDir, args.rootDir)
290
346
  );
291
347
  const latest = new Map<string, AiWritebackRecord>();
292
348
  for (const entry of entries) {
@@ -301,7 +357,9 @@ async function appendEvent(
301
357
  event: AiJournalEvent
302
358
  ): Promise<void> {
303
359
  const pathValue = facultAiJournalPath(homeDir, rootDir);
304
- const existing = await readJsonLines<AiJournalEvent>(pathValue);
360
+ const existing = await readJsonLinesFromPaths<AiJournalEvent>(
361
+ aiJournalReadPaths(homeDir, rootDir)
362
+ );
305
363
  const next = {
306
364
  ...event,
307
365
  id: nextId(
@@ -605,11 +663,15 @@ async function nextProposalId(
605
663
  homeDir: string,
606
664
  rootDir: string
607
665
  ): Promise<string> {
608
- const dir = facultAiProposalDir(homeDir, rootDir);
609
- const entries = await readdir(dir).catch(() => [] as string[]);
610
- const ids = entries
611
- .filter((entry) => entry.endsWith(".json"))
612
- .map((entry) => basename(entry, ".json"));
666
+ const ids: string[] = [];
667
+ for (const dir of aiProposalReadDirs(homeDir, rootDir)) {
668
+ const entries = await readdir(dir).catch(() => [] as string[]);
669
+ ids.push(
670
+ ...entries
671
+ .filter((entry) => entry.endsWith(".json"))
672
+ .map((entry) => basename(entry, ".json"))
673
+ );
674
+ }
613
675
  return nextId("EV", ids);
614
676
  }
615
677
 
@@ -802,18 +864,19 @@ export async function listProposals(args?: {
802
864
  throw new Error("listProposals requires a rootDir");
803
865
  }
804
866
  const homeDir = args?.homeDir ?? process.env.HOME ?? "";
805
- const dir = facultAiProposalDir(homeDir, args?.rootDir);
806
- const entries = await readdir(dir).catch(() => [] as string[]);
807
- const out: AiProposalRecord[] = [];
808
- for (const entry of entries.sort()) {
809
- if (!entry.endsWith(".json")) {
810
- continue;
867
+ const byId = new Map<string, AiProposalRecord>();
868
+ for (const dir of [...aiProposalReadDirs(homeDir, args.rootDir)].reverse()) {
869
+ const entries = await readdir(dir).catch(() => [] as string[]);
870
+ for (const entry of entries.sort()) {
871
+ if (!entry.endsWith(".json")) {
872
+ continue;
873
+ }
874
+ const raw = await readFile(join(dir, entry), "utf8");
875
+ const parsed = JSON.parse(raw) as AiProposalRecord;
876
+ byId.set(parsed.id, parsed);
811
877
  }
812
- const raw = await readFile(join(dir, entry), "utf8");
813
- const parsed = JSON.parse(raw) as AiProposalRecord;
814
- out.push(parsed);
815
878
  }
816
- return out;
879
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
817
880
  }
818
881
 
819
882
  export async function showProposal(
@@ -821,15 +884,15 @@ export async function showProposal(
821
884
  args: { homeDir?: string; rootDir: string }
822
885
  ): Promise<AiProposalRecord | null> {
823
886
  const homeDir = args.homeDir ?? process.env.HOME ?? "";
824
- const pathValue = join(
825
- facultAiProposalDir(homeDir, args.rootDir),
826
- `${id}.json`
827
- );
828
- if (!(await fileExists(pathValue))) {
829
- return null;
887
+ for (const dir of aiProposalReadDirs(homeDir, args.rootDir)) {
888
+ const pathValue = join(dir, `${id}.json`);
889
+ if (!(await fileExists(pathValue))) {
890
+ continue;
891
+ }
892
+ const raw = await readFile(pathValue, "utf8");
893
+ return JSON.parse(raw) as AiProposalRecord;
830
894
  }
831
- const raw = await readFile(pathValue, "utf8");
832
- return JSON.parse(raw) as AiProposalRecord;
895
+ return null;
833
896
  }
834
897
 
835
898
  function promoteTargetRef(target: string, to: "global"): string {
@@ -1082,9 +1145,13 @@ export async function draftProposal(
1082
1145
  const patchPath = patchRefForProposal(homeDir, args.rootDir, id);
1083
1146
  await mkdir(dirname(draftPath), { recursive: true });
1084
1147
  const generatedBody = renderDraftBody(current, writebacks);
1148
+ const existingDraftPath = await firstExistingFile([
1149
+ draftPath,
1150
+ ...current.draftRefs.filter((pathValue) => pathValue.endsWith(".md")),
1151
+ ]);
1085
1152
  const priorDraft =
1086
- args.append && (await fileExists(draftPath))
1087
- ? await readFile(draftPath, "utf8")
1153
+ args.append && existingDraftPath
1154
+ ? await readFile(existingDraftPath, "utf8")
1088
1155
  : null;
1089
1156
  const draftBody = args.append
1090
1157
  ? `${(priorDraft ?? generatedBody).trimEnd()}\n\n## Draft Revision\n${args.append.trim()}\n`
package/src/doctor.ts CHANGED
@@ -727,6 +727,17 @@ export async function doctorCommand(argv: string[]) {
727
727
  return;
728
728
  }
729
729
 
730
+ if (
731
+ result.source === "legacy" &&
732
+ projectRootFromAiRoot(rootDir, home) &&
733
+ (await hasCanonicalSource(rootDir))
734
+ ) {
735
+ console.log(
736
+ "Legacy repo-local generated AI state detected. Run `fclt doctor --repair` or `fclt index` to migrate it into machine-local project state."
737
+ );
738
+ return;
739
+ }
740
+
730
741
  if (result.source === "legacy") {
731
742
  console.log(
732
743
  "Legacy root index detected. Run `fclt doctor --repair` to reconcile it."
package/src/paths.ts CHANGED
@@ -229,6 +229,9 @@ export function facultStateDir(
229
229
  rootDir?: string
230
230
  ): string {
231
231
  const resolvedRoot = rootDir ?? facultRootDir(home);
232
+ if (projectRootFromAiRoot(resolvedRoot, home)) {
233
+ return facultMachineStateDir(home, resolvedRoot);
234
+ }
232
235
  if (shouldUsePreferredGlobalStateDir(resolvedRoot, home)) {
233
236
  return preferredGlobalFacultStateDir(home);
234
237
  }
@@ -317,6 +320,36 @@ export function facultAiStateDir(
317
320
  return join(facultGeneratedStateDir({ home, rootDir }), "ai");
318
321
  }
319
322
 
323
+ export function legacyRepoLocalFacultAiStateDir(
324
+ home: string = defaultHomeDir(),
325
+ rootDir?: string
326
+ ): string | null {
327
+ const resolvedRoot = rootDir ?? facultRootDir(home);
328
+ return projectRootFromAiRoot(resolvedRoot, home)
329
+ ? join(resolvedRoot, ".facult", "ai")
330
+ : null;
331
+ }
332
+
333
+ export function legacyFacultAiStateDirs(
334
+ home: string = defaultHomeDir(),
335
+ rootDir?: string
336
+ ): string[] {
337
+ const resolvedRoot = rootDir ?? facultRootDir(home);
338
+ const current = resolve(facultAiStateDir(home, resolvedRoot));
339
+ const legacyDirs = [
340
+ legacyRepoLocalFacultAiStateDir(home, resolvedRoot),
341
+ join(legacyFacultStateDirForRoot(resolvedRoot, home), "ai"),
342
+ ];
343
+
344
+ return [
345
+ ...new Set(
346
+ legacyDirs
347
+ .filter((pathValue): pathValue is string => Boolean(pathValue))
348
+ .filter((pathValue) => resolve(pathValue) !== current)
349
+ ),
350
+ ];
351
+ }
352
+
320
353
  export function facultAiIndexPath(
321
354
  home: string = defaultHomeDir(),
322
355
  rootDir?: string