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 +6 -5
- package/package.json +1 -1
- package/src/ai-state.ts +78 -31
- package/src/ai.ts +95 -28
- package/src/doctor.ts +11 -0
- package/src/paths.ts +33 -0
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
|
|
299
|
-
-
|
|
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
|
|
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:
|
|
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
|
-
-
|
|
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
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
|
|
193
|
+
for (const legacyPath of legacyGeneratedAiIndexPaths(
|
|
164
194
|
args.homeDir,
|
|
165
195
|
args.rootDir
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
268
|
+
for (const legacyGeneratedPath of legacyGeneratedAiGraphPaths(
|
|
237
269
|
args.homeDir,
|
|
238
270
|
args.rootDir
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
289
|
-
|
|
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
|
|
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
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
.
|
|
612
|
-
|
|
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
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
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
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
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 &&
|
|
1087
|
-
? await readFile(
|
|
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
|