facult 2.17.3 → 2.17.6

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.17.3",
3
+ "version": "2.17.6",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ai.ts CHANGED
@@ -491,6 +491,18 @@ async function writeProposalReviewArtifact(args: {
491
491
  await Bun.write(pathValue, `${body.trimEnd()}\n`);
492
492
  }
493
493
 
494
+ async function currentDraftBodyForProposal(
495
+ proposal: AiProposalRecord
496
+ ): Promise<string | null> {
497
+ const draftPath = proposal.draftRefs.find((pathValue) =>
498
+ pathValue.endsWith(".md")
499
+ );
500
+ if (!(draftPath && (await fileExists(draftPath)))) {
501
+ return null;
502
+ }
503
+ return readFile(draftPath, "utf8");
504
+ }
505
+
494
506
  function slugToTitle(value: string): string {
495
507
  return value
496
508
  .split(SLUG_SPLIT_RE)
@@ -988,7 +1000,11 @@ export async function proposeEvolution(args: {
988
1000
  : null;
989
1001
 
990
1002
  const candidates = writebacks.filter((entry) => {
991
- if (entry.status === "dismissed" || entry.status === "superseded") {
1003
+ if (
1004
+ entry.status === "dismissed" ||
1005
+ entry.status === "resolved" ||
1006
+ entry.status === "superseded"
1007
+ ) {
992
1008
  return false;
993
1009
  }
994
1010
  if (entry.evidence.length === 0) {
@@ -1371,6 +1387,7 @@ export async function refreshAiReviewArtifacts(args: {
1371
1387
  rootDir: args.rootDir,
1372
1388
  proposal,
1373
1389
  writebacks: sourceWritebacks,
1390
+ draftBody: await currentDraftBodyForProposal(proposal),
1374
1391
  });
1375
1392
  }
1376
1393
 
package/src/doctor.ts CHANGED
@@ -476,7 +476,12 @@ async function repairCanonicalGlobalDocs(args: {
476
476
  home: string;
477
477
  rootDir: string;
478
478
  }): Promise<{ changed: boolean; backupPath?: string }> {
479
- const inspected = await inspectCanonicalGlobalDocs(args.rootDir);
479
+ if (projectRootFromAiRoot(args.rootDir, args.home)) {
480
+ return { changed: false };
481
+ }
482
+ const inspected = await inspectCanonicalGlobalDocs(args.rootDir, {
483
+ projectRoot: null,
484
+ });
480
485
  if (!(inspected.exists && !inspected.valid)) {
481
486
  return { changed: false };
482
487
  }
@@ -494,13 +499,13 @@ async function repairCanonicalGlobalDocs(args: {
494
499
  await copyFile(targetPath, backupPath);
495
500
  await mkdir(dirname(targetPath), { recursive: true });
496
501
  await writeFile(targetPath, await readFile(sourcePath, "utf8"), "utf8");
497
- await copyMissingBuiltinOperatingModelSnippets(args.rootDir);
502
+ await repairBuiltinOperatingModelSnippets(args.rootDir);
498
503
  await ensureAiIndexPath({ homeDir: args.home, rootDir: args.rootDir });
499
504
  await ensureAiGraphPath({ homeDir: args.home, rootDir: args.rootDir });
500
505
  return { changed: true, backupPath };
501
506
  }
502
507
 
503
- async function copyMissingBuiltinOperatingModelSnippets(
508
+ async function repairBuiltinOperatingModelSnippets(
504
509
  rootDir: string
505
510
  ): Promise<void> {
506
511
  const relPaths = [
@@ -515,7 +520,10 @@ async function copyMissingBuiltinOperatingModelSnippets(
515
520
  for (const relPath of relPaths) {
516
521
  const sourcePath = join(sourceRoot, relPath);
517
522
  const targetPath = join(rootDir, relPath);
518
- if (await pathExists(targetPath)) {
523
+ const existing = (await pathExists(targetPath))
524
+ ? await readFile(targetPath, "utf8")
525
+ : null;
526
+ if (existing !== null && existing.trim().length > 0) {
519
527
  continue;
520
528
  }
521
529
  await mkdir(dirname(targetPath), { recursive: true });
@@ -601,7 +609,10 @@ const UNRESOLVED_REFS_TEMPLATE_RE = /\$\{refs\.([A-Za-z0-9_.-]+)\}/g;
601
609
  const FCLTY_BLOCK_RE =
602
610
  /<!--\s*fclty:([^>]+?)\s*-->([\s\S]*?)<!--\s*\/fclty:\1\s*-->/g;
603
611
 
604
- async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
612
+ async function inspectCanonicalGlobalDocs(
613
+ rootDir: string,
614
+ opts: { projectRoot?: string | null } = {}
615
+ ): Promise<{
605
616
  exists: boolean;
606
617
  valid: boolean;
607
618
  issues: DoctorIssue[];
@@ -613,6 +624,12 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
613
624
 
614
625
  const text = await readFile(pathValue, "utf8");
615
626
  const issues: DoctorIssue[] = [];
627
+ const refreshCommand = opts.projectRoot
628
+ ? "fclt templates init project-ai --force"
629
+ : "fclt templates init operating-model --global --force";
630
+ const docLabel = opts.projectRoot
631
+ ? "project AGENTS.global.md"
632
+ : "AGENTS.global.md";
616
633
  const withSnippets = await renderSnippetText({
617
634
  text,
618
635
  filePath: pathValue,
@@ -623,7 +640,7 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
623
640
  severity: "warning",
624
641
  code: "canonical-global-docs-render-error",
625
642
  message: error,
626
- fix: "Review AGENTS.global.md snippet markers or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
643
+ fix: `Review ${docLabel} snippet markers or refresh the selected capability root with \`${refreshCommand}\`.`,
627
644
  });
628
645
  }
629
646
  const rendered = await renderCanonicalText(withSnippets.text, {
@@ -636,9 +653,8 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
636
653
  issues.push({
637
654
  severity: "warning",
638
655
  code: "canonical-global-docs-unresolved-template",
639
- message:
640
- "Rendered AGENTS.global.md contains unresolved template references.",
641
- fix: "Review AGENTS.global.md refs or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
656
+ message: `Rendered ${docLabel} contains unresolved template references.`,
657
+ fix: `Review ${docLabel} refs or refresh the selected capability root with \`${refreshCommand}\`.`,
642
658
  });
643
659
  }
644
660
 
@@ -654,8 +670,8 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
654
670
  issues.push({
655
671
  severity: "warning",
656
672
  code: "canonical-global-docs-empty-managed-sections",
657
- message: `Rendered AGENTS.global.md has empty fclty managed sections: ${emptySections.join(", ")}.`,
658
- fix: "Add the missing snippets or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
673
+ message: `Rendered ${docLabel} has empty fclty managed sections: ${emptySections.join(", ")}.`,
674
+ fix: `Add the missing snippets or refresh the selected capability root with \`${refreshCommand}\`.`,
659
675
  });
660
676
  }
661
677
 
@@ -976,7 +992,7 @@ export async function buildDoctorReport(opts?: {
976
992
  pathExists(generatedGraph),
977
993
  pathExists(writebackReviewDir),
978
994
  pathExists(evolutionReviewDir),
979
- inspectCanonicalGlobalDocs(rootDir),
995
+ inspectCanonicalGlobalDocs(rootDir, { projectRoot }),
980
996
  inspectCanonicalTemplateRefs(rootDir),
981
997
  planProjectSyncPolicyRepair({ home, rootDir }),
982
998
  ]);
@@ -1019,9 +1035,15 @@ export async function buildDoctorReport(opts?: {
1019
1035
  }
1020
1036
  if (canonicalGlobalDocs.exists && !canonicalGlobalDocs.valid) {
1021
1037
  actions.push({
1022
- id: "refresh-global-operating-model",
1023
- label: "Refresh global operating model",
1024
- command: "fclt templates init operating-model --global --force",
1038
+ id: projectRoot
1039
+ ? "refresh-project-operating-model"
1040
+ : "refresh-global-operating-model",
1041
+ label: projectRoot
1042
+ ? "Refresh project operating model"
1043
+ : "Refresh global operating model",
1044
+ command: projectRoot
1045
+ ? "fclt templates init project-ai --force"
1046
+ : "fclt templates init operating-model --global --force",
1025
1047
  risk: "canonical_write",
1026
1048
  });
1027
1049
  }
@@ -1369,7 +1391,9 @@ export async function doctorCommand(argv: string[]) {
1369
1391
  `Project sync is still implicit for managed tools (${projectSyncRepairTools.join(", ")}). Run \`fclt doctor --repair\` to write explicit [project_sync.<tool>] entries.`
1370
1392
  );
1371
1393
  }
1372
- const canonicalGlobalDocs = await inspectCanonicalGlobalDocs(rootDir);
1394
+ const canonicalGlobalDocs = await inspectCanonicalGlobalDocs(rootDir, {
1395
+ projectRoot: projectRootFromAiRoot(rootDir, home),
1396
+ });
1373
1397
  if (canonicalGlobalDocs.exists && !canonicalGlobalDocs.valid) {
1374
1398
  for (const issue of canonicalGlobalDocs.issues) {
1375
1399
  console.log(`${issue.message} ${issue.fix ?? ""}`.trim());
@@ -6,7 +6,7 @@ import {
6
6
  facultBuiltinAgentsGlobalSourcePath,
7
7
  facultBuiltinPackRoot,
8
8
  } from "./builtin";
9
- import { projectRootFromAiRoot } from "./paths";
9
+ import { pathIsInsideOrEqual, projectRootFromAiRoot } from "./paths";
10
10
  import { projectSyncAllowsToolSurface } from "./project-sync";
11
11
  import { renderSnippetText } from "./snippets";
12
12
 
@@ -233,7 +233,7 @@ async function renderSourceTarget(args: {
233
233
  }): Promise<string> {
234
234
  const raw = await Bun.file(args.sourcePath).text();
235
235
  const builtinRoot = facultBuiltinPackRoot();
236
- const sourceRoot = args.sourcePath.startsWith(`${builtinRoot}/`)
236
+ const sourceRoot = pathIsInsideOrEqual(args.sourcePath, builtinRoot)
237
237
  ? builtinRoot
238
238
  : args.rootDir;
239
239
  const withSnippets = await renderSnippetText({
package/src/manage.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  facultMachineStateDir,
49
49
  facultRootDir,
50
50
  legacyFacultStateDirForRoot,
51
+ pathIsInsideOrEqual,
51
52
  projectRootFromAiRoot,
52
53
  } from "./paths";
53
54
  import { loadProjectToolSyncPolicy } from "./project-sync";
@@ -289,8 +290,8 @@ async function isGeneratedOnlyProjectRoot(args: {
289
290
  function renderedSourceKindForPath(
290
291
  sourcePath: string
291
292
  ): ManagedRenderedTargetState["sourceKind"] {
292
- return sourcePath.startsWith(facultBuiltinPackRoot()) ||
293
- sourcePath.startsWith(facultBuiltinCodexPluginRoot())
293
+ return pathIsInsideOrEqual(sourcePath, facultBuiltinPackRoot()) ||
294
+ pathIsInsideOrEqual(sourcePath, facultBuiltinCodexPluginRoot())
294
295
  ? "builtin"
295
296
  : "canonical";
296
297
  }
package/src/paths.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
4
+ import {
5
+ basename,
6
+ dirname,
7
+ isAbsolute,
8
+ join,
9
+ relative,
10
+ resolve,
11
+ win32,
12
+ } from "node:path";
5
13
  import { parseJsonLenient } from "./util/json";
6
14
 
15
+ const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
16
+
7
17
  export interface FacultConfig {
8
18
  /**
9
19
  * Override the canonical root directory.
@@ -88,6 +98,41 @@ export function preferredGlobalAiRoot(home: string = defaultHomeDir()): string {
88
98
  return join(home, ".ai");
89
99
  }
90
100
 
101
+ function looksLikeWindowsAbsolutePath(pathValue: string): boolean {
102
+ return (
103
+ WINDOWS_ABSOLUTE_PATH_RE.test(pathValue) || pathValue.startsWith("\\\\")
104
+ );
105
+ }
106
+
107
+ function relativePathIsInsideOrEqual(args: {
108
+ rel: string;
109
+ isAbsolutePath: (pathValue: string) => boolean;
110
+ }): boolean {
111
+ const { isAbsolutePath, rel } = args;
112
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolutePath(rel));
113
+ }
114
+
115
+ export function pathIsInsideOrEqual(
116
+ pathValue: string,
117
+ rootDir: string
118
+ ): boolean {
119
+ if (
120
+ looksLikeWindowsAbsolutePath(pathValue) &&
121
+ looksLikeWindowsAbsolutePath(rootDir)
122
+ ) {
123
+ return relativePathIsInsideOrEqual({
124
+ isAbsolutePath: win32.isAbsolute,
125
+ rel: win32.relative(rootDir, pathValue),
126
+ });
127
+ }
128
+
129
+ const rel = relative(rootDir, pathValue);
130
+ return relativePathIsInsideOrEqual({
131
+ isAbsolutePath: isAbsolute,
132
+ rel,
133
+ });
134
+ }
135
+
91
136
  export function preferredGlobalFacultStateDir(
92
137
  home: string = defaultHomeDir()
93
138
  ): string {
@@ -195,9 +195,6 @@ async function activeFcltUsesMiseNpmFacult(): Promise<boolean> {
195
195
  if (looksLikeMiseNpmFacultExecutable(fcltPath ?? "")) {
196
196
  return true;
197
197
  }
198
- if (await miseHasCurrentFacultTool()) {
199
- return true;
200
- }
201
198
  if (!looksLikeMiseShim(fcltPath)) {
202
199
  return false;
203
200
  }
@@ -218,20 +215,21 @@ async function activeFcltUsesMiseNpmFacult(): Promise<boolean> {
218
215
  return looksLikeMiseNpmFacultExecutable(stdout.trim());
219
216
  }
220
217
 
221
- async function miseHasCurrentFacultTool(): Promise<boolean> {
222
- const proc = Bun.spawn({
223
- cmd: ["mise", "current", `npm:${PACKAGE_NAME}`],
224
- stdin: "ignore",
225
- stdout: "ignore",
226
- stderr: "ignore",
227
- env: process.env,
228
- });
229
- return (await proc.exited) === 0;
218
+ export function buildCommandLookupFallback(
219
+ command: string,
220
+ platform: NodeJS.Platform = process.platform
221
+ ): string[] {
222
+ if (platform === "win32") {
223
+ return ["where.exe", command];
224
+ }
225
+ const quoted = `'${command.replaceAll("'", "'\\''")}'`;
226
+ return ["sh", "-lc", `command -v ${quoted}`];
230
227
  }
231
228
 
232
229
  async function resolveCommandPath(command: string): Promise<string | null> {
230
+ const cmd = buildCommandLookupFallback(command);
233
231
  const proc = Bun.spawn({
234
- cmd: ["sh", "-lc", `command -v ${command}`],
232
+ cmd,
235
233
  stdin: "ignore",
236
234
  stdout: "pipe",
237
235
  stderr: "ignore",
@@ -247,6 +245,17 @@ async function resolveCommandPath(command: string): Promise<string | null> {
247
245
  return stdout.trim() || null;
248
246
  }
249
247
 
248
+ export function looksLikeMiseNpmFacultExecutableForVersion(
249
+ executablePath: string,
250
+ version: string
251
+ ): boolean {
252
+ const normalized = executablePath.split("\\").join(sep);
253
+ return (
254
+ looksLikeMiseNpmFacultExecutable(normalized) &&
255
+ normalized.includes(`${sep}npm-facult${sep}${version}${sep}`)
256
+ );
257
+ }
258
+
250
259
  function resolvePlatformTarget(): {
251
260
  platform: string;
252
261
  arch: string;
@@ -516,17 +525,38 @@ async function assertActiveFcltVersion(
516
525
  expectedVersion: string,
517
526
  packageManager: PackageManager
518
527
  ): Promise<void> {
519
- const cmd =
520
- packageManager === "mise"
521
- ? [
522
- "mise",
523
- "exec",
524
- `npm:${PACKAGE_NAME}@${expectedVersion}`,
525
- "--",
526
- "fclt",
527
- "--version",
528
- ]
529
- : ["fclt", "--version"];
528
+ let cmd = ["fclt", "--version"];
529
+ if (packageManager === "mise") {
530
+ const which = Bun.spawn({
531
+ cmd: ["mise", "which", "fclt"],
532
+ stdin: "ignore",
533
+ stdout: "pipe",
534
+ stderr: "pipe",
535
+ env: process.env,
536
+ });
537
+ const [whichStdout, whichStderr, whichCode] = await Promise.all([
538
+ new Response(which.stdout).text(),
539
+ new Response(which.stderr).text(),
540
+ which.exited,
541
+ ]);
542
+ const resolvedPath = whichStdout.trim();
543
+ if (whichCode !== 0) {
544
+ throw new Error(
545
+ `Updated package, but could not verify active mise fclt path: ${whichStderr.trim()}`
546
+ );
547
+ }
548
+ if (
549
+ !looksLikeMiseNpmFacultExecutableForVersion(resolvedPath, expectedVersion)
550
+ ) {
551
+ throw new Error(
552
+ [
553
+ `Updated package to ${expectedVersion}, but mise resolves fclt to ${resolvedPath || "nothing"}.`,
554
+ "Run `mise which fclt` and `mise current npm:facult` to inspect the active tool selection.",
555
+ ].join("\n")
556
+ );
557
+ }
558
+ cmd = [resolvedPath, "--version"];
559
+ }
530
560
  const proc = Bun.spawn({
531
561
  cmd,
532
562
  stdin: "ignore",