facult 2.13.5 → 2.14.0

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.
Files changed (35) hide show
  1. package/README.md +31 -10
  2. package/assets/packs/facult-operating-model/AGENTS.global.md +41 -41
  3. package/assets/packs/facult-operating-model/agents/evolution-planner/agent.toml +3 -0
  4. package/assets/packs/facult-operating-model/agents/integration-auditor/agent.toml +8 -1
  5. package/assets/packs/facult-operating-model/agents/scope-promoter/agent.toml +8 -1
  6. package/assets/packs/facult-operating-model/agents/writeback-curator/agent.toml +2 -0
  7. package/assets/packs/facult-operating-model/instructions/CAPABILITY_COMPOSITION.md +4 -4
  8. package/assets/packs/facult-operating-model/instructions/EVOLUTION.md +3 -3
  9. package/assets/packs/facult-operating-model/instructions/INTEGRATION.md +44 -0
  10. package/assets/packs/facult-operating-model/instructions/LEARNING_AND_WRITEBACK.md +1 -1
  11. package/assets/packs/facult-operating-model/instructions/PROJECT_CAPABILITY.md +35 -0
  12. package/assets/packs/facult-operating-model/instructions/WORK_UNITS.md +48 -0
  13. package/assets/packs/facult-operating-model/skills/capability-evolution/SKILL.md +25 -6
  14. package/assets/packs/facult-operating-model/skills/project-operating-layer-design/SKILL.md +33 -0
  15. package/assets/packs/facult-operating-model/snippets/global/baseline.md +3 -0
  16. package/assets/packs/facult-operating-model/snippets/global/core/feedback-loops.md +11 -0
  17. package/assets/packs/facult-operating-model/snippets/global/core/verification.md +6 -0
  18. package/assets/packs/facult-operating-model/snippets/global/core/work-units.md +7 -0
  19. package/assets/packs/facult-operating-model/snippets/global/core/writeback.md +9 -0
  20. package/docs/README.md +3 -0
  21. package/docs/assets/fclt-capability-loop.png +0 -0
  22. package/docs/built-in-pack.md +13 -0
  23. package/docs/composable-capability.md +21 -22
  24. package/docs/concepts.md +4 -1
  25. package/docs/pack-upgrades.md +67 -0
  26. package/docs/reference.md +10 -2
  27. package/docs/work-units.md +96 -0
  28. package/package.json +1 -1
  29. package/src/agents.ts +17 -0
  30. package/src/builtin-assets.ts +2 -1
  31. package/src/doctor.ts +238 -7
  32. package/src/global-docs.ts +10 -3
  33. package/src/remote.ts +149 -14
  34. package/src/snippets-cli.ts +2 -2
  35. package/src/snippets.ts +1 -1
package/src/doctor.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs/promises";
16
16
  import { homedir } from "node:os";
17
17
  import { dirname, join } from "node:path";
18
+ import { renderCanonicalText } from "./agents";
18
19
  import {
19
20
  ensureAiGraphPath,
20
21
  ensureAiIndexPath,
@@ -41,6 +42,7 @@ import {
41
42
  loadConfiguredProjectSyncTools,
42
43
  writeProjectSyncPolicy,
43
44
  } from "./project-sync";
45
+ import { renderSnippetText } from "./snippets";
44
46
  import { packageVersion } from "./status";
45
47
 
46
48
  const TOML_FILE_SUFFIX_RE = /\.toml$/;
@@ -104,6 +106,9 @@ export interface DoctorReport {
104
106
  evolutionReviewDirExists: boolean;
105
107
  canonicalGlobalDocsValid: boolean;
106
108
  canonicalGlobalDocsIssueCodes: string[];
109
+ canonicalTemplateRefsValid: boolean;
110
+ canonicalTemplateRefsIssueCodes: string[];
111
+ canonicalTemplateRefsIssuePaths: string[];
107
112
  projectSyncRepairNeeded: boolean;
108
113
  projectSyncRepairTools: string[];
109
114
  };
@@ -446,6 +451,24 @@ async function repairLegacyCodexAuthoringLayout(args: {
446
451
  return { changed, conflicts };
447
452
  }
448
453
 
454
+ interface CanonicalTemplateIssue {
455
+ path: string;
456
+ relPath: string;
457
+ code: string;
458
+ message: string;
459
+ }
460
+
461
+ interface CanonicalTemplateRefsInspection {
462
+ valid: boolean;
463
+ issues: CanonicalTemplateIssue[];
464
+ }
465
+
466
+ interface CanonicalTemplateRefsRepair {
467
+ changed: boolean;
468
+ repairedPaths: string[];
469
+ backupPaths: string[];
470
+ }
471
+
449
472
  async function repairCanonicalGlobalDocs(args: {
450
473
  home: string;
451
474
  rootDir: string;
@@ -468,11 +491,35 @@ async function repairCanonicalGlobalDocs(args: {
468
491
  await copyFile(targetPath, backupPath);
469
492
  await mkdir(dirname(targetPath), { recursive: true });
470
493
  await writeFile(targetPath, await readFile(sourcePath, "utf8"), "utf8");
494
+ await copyMissingBuiltinOperatingModelSnippets(args.rootDir);
471
495
  await ensureAiIndexPath({ homeDir: args.home, rootDir: args.rootDir });
472
496
  await ensureAiGraphPath({ homeDir: args.home, rootDir: args.rootDir });
473
497
  return { changed: true, backupPath };
474
498
  }
475
499
 
500
+ async function copyMissingBuiltinOperatingModelSnippets(
501
+ rootDir: string
502
+ ): Promise<void> {
503
+ const relPaths = [
504
+ "snippets/global/baseline.md",
505
+ "snippets/global/core/work-units.md",
506
+ "snippets/global/core/feedback-loops.md",
507
+ "snippets/global/core/verification.md",
508
+ "snippets/global/core/writeback.md",
509
+ ];
510
+ const sourceRoot = facultBuiltinPackRoot();
511
+
512
+ for (const relPath of relPaths) {
513
+ const sourcePath = join(sourceRoot, relPath);
514
+ const targetPath = join(rootDir, relPath);
515
+ if (await pathExists(targetPath)) {
516
+ continue;
517
+ }
518
+ await mkdir(dirname(targetPath), { recursive: true });
519
+ await copyFile(sourcePath, targetPath);
520
+ }
521
+ }
522
+
476
523
  async function listProjectSkillNames(rootDir: string): Promise<string[]> {
477
524
  const skillsDir = join(rootDir, "skills");
478
525
  const entries = await readdir(skillsDir, { withFileTypes: true }).catch(
@@ -547,6 +594,7 @@ async function hasProjectGlobalDocs(rootDir: string): Promise<boolean> {
547
594
  }
548
595
 
549
596
  const UNRESOLVED_TEMPLATE_REF_RE = /\$\{[^}\n]+\}/g;
597
+ const UNRESOLVED_REFS_TEMPLATE_RE = /\$\{refs\.([A-Za-z0-9_.-]+)\}/g;
550
598
  const FCLTY_BLOCK_RE =
551
599
  /<!--\s*fclty:([^>]+?)\s*-->([\s\S]*?)<!--\s*\/fclty:\1\s*-->/g;
552
600
 
@@ -562,19 +610,37 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
562
610
 
563
611
  const text = await readFile(pathValue, "utf8");
564
612
  const issues: DoctorIssue[] = [];
565
- const unresolvedRefs = new Set(text.match(UNRESOLVED_TEMPLATE_REF_RE) ?? []);
613
+ const withSnippets = await renderSnippetText({
614
+ text,
615
+ filePath: pathValue,
616
+ rootDir,
617
+ });
618
+ for (const error of withSnippets.errors) {
619
+ issues.push({
620
+ severity: "warning",
621
+ code: "canonical-global-docs-render-error",
622
+ message: error,
623
+ fix: "Review AGENTS.global.md snippet markers or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
624
+ });
625
+ }
626
+ const rendered = await renderCanonicalText(withSnippets.text, {
627
+ rootDir,
628
+ });
629
+ const unresolvedRefs = new Set(
630
+ rendered.match(UNRESOLVED_TEMPLATE_REF_RE) ?? []
631
+ );
566
632
  if (unresolvedRefs.size > 0) {
567
633
  issues.push({
568
634
  severity: "warning",
569
635
  code: "canonical-global-docs-unresolved-template",
570
636
  message:
571
- "Canonical AGENTS.global.md contains unresolved template references.",
572
- fix: "Review the file or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
637
+ "Rendered AGENTS.global.md contains unresolved template references.",
638
+ fix: "Review AGENTS.global.md refs or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
573
639
  });
574
640
  }
575
641
 
576
642
  const emptySections: string[] = [];
577
- for (const match of text.matchAll(FCLTY_BLOCK_RE)) {
643
+ for (const match of rendered.matchAll(FCLTY_BLOCK_RE)) {
578
644
  const key = match[1]?.trim();
579
645
  const body = match[2]?.trim();
580
646
  if (key && !body) {
@@ -585,8 +651,8 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
585
651
  issues.push({
586
652
  severity: "warning",
587
653
  code: "canonical-global-docs-empty-managed-sections",
588
- message: `Canonical AGENTS.global.md has empty fclty managed sections: ${emptySections.join(", ")}.`,
589
- fix: "Review the file or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
654
+ message: `Rendered AGENTS.global.md has empty fclty managed sections: ${emptySections.join(", ")}.`,
655
+ fix: "Add the missing snippets or refresh the built-in operating model with `fclt templates init operating-model --global --force`.",
590
656
  });
591
657
  }
592
658
 
@@ -597,6 +663,125 @@ async function inspectCanonicalGlobalDocs(rootDir: string): Promise<{
597
663
  };
598
664
  }
599
665
 
666
+ const CANONICAL_TEMPLATE_REF_DIRS = ["instructions"] as const;
667
+
668
+ function canonicalRefValues(rootDir: string): Record<string, string> {
669
+ return {
670
+ evolution: join(rootDir, "instructions", "EVOLUTION.md"),
671
+ feedback_loops: join(rootDir, "instructions", "FEEDBACK_LOOPS.md"),
672
+ integration: join(rootDir, "instructions", "INTEGRATION.md"),
673
+ learning_writeback: join(
674
+ rootDir,
675
+ "instructions",
676
+ "LEARNING_AND_WRITEBACK.md"
677
+ ),
678
+ project_capability: join(rootDir, "instructions", "PROJECT_CAPABILITY.md"),
679
+ verification: join(rootDir, "instructions", "VERIFICATION.md"),
680
+ work_units: join(rootDir, "instructions", "WORK_UNITS.md"),
681
+ };
682
+ }
683
+
684
+ function resolveKnownCanonicalRefs(text: string, rootDir: string): string {
685
+ const refs = canonicalRefValues(rootDir);
686
+ return text.replace(UNRESOLVED_REFS_TEMPLATE_RE, (match, key: string) => {
687
+ return refs[key] ?? match;
688
+ });
689
+ }
690
+
691
+ async function listCanonicalMarkdownFiles(rootDir: string): Promise<string[]> {
692
+ const out: string[] = [];
693
+
694
+ async function visit(dir: string): Promise<void> {
695
+ const entries = await readdir(dir, { withFileTypes: true }).catch(
696
+ () => [] as import("node:fs").Dirent[]
697
+ );
698
+ for (const entry of entries) {
699
+ if (entry.name.startsWith(".")) {
700
+ continue;
701
+ }
702
+ const pathValue = join(dir, entry.name);
703
+ if (entry.isDirectory()) {
704
+ await visit(pathValue);
705
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
706
+ out.push(pathValue);
707
+ }
708
+ }
709
+ }
710
+
711
+ for (const relDir of CANONICAL_TEMPLATE_REF_DIRS) {
712
+ await visit(join(rootDir, relDir));
713
+ }
714
+ return out.sort((a, b) => a.localeCompare(b));
715
+ }
716
+
717
+ function relPathFromRoot(rootDir: string, pathValue: string): string {
718
+ return pathValue.startsWith(`${rootDir}/`)
719
+ ? pathValue.slice(rootDir.length + 1)
720
+ : pathValue;
721
+ }
722
+
723
+ async function inspectCanonicalTemplateRefs(
724
+ rootDir: string
725
+ ): Promise<CanonicalTemplateRefsInspection> {
726
+ const files = await listCanonicalMarkdownFiles(rootDir);
727
+ const issues: CanonicalTemplateIssue[] = [];
728
+
729
+ for (const pathValue of files) {
730
+ const text = await readFile(pathValue, "utf8");
731
+ const refs = new Set(text.match(UNRESOLVED_REFS_TEMPLATE_RE) ?? []);
732
+ if (refs.size === 0) {
733
+ continue;
734
+ }
735
+ const relPath = relPathFromRoot(rootDir, pathValue);
736
+ issues.push({
737
+ path: pathValue,
738
+ relPath,
739
+ code: "canonical-source-unresolved-template-ref",
740
+ message: `${relPath} contains unresolved template refs: ${[...refs].join(", ")}.`,
741
+ });
742
+ }
743
+
744
+ return {
745
+ valid: issues.length === 0,
746
+ issues,
747
+ };
748
+ }
749
+
750
+ async function repairCanonicalTemplateRefs(
751
+ rootDir: string
752
+ ): Promise<CanonicalTemplateRefsRepair> {
753
+ const files = await listCanonicalMarkdownFiles(rootDir);
754
+ const repairedPaths: string[] = [];
755
+ const backupPaths: string[] = [];
756
+
757
+ for (const pathValue of files) {
758
+ const before = await readFile(pathValue, "utf8");
759
+ const after = resolveKnownCanonicalRefs(before, rootDir);
760
+ if (after === before) {
761
+ continue;
762
+ }
763
+ const relPath = relPathFromRoot(rootDir, pathValue);
764
+ const backupPath = join(
765
+ rootDir,
766
+ ".facult",
767
+ "backups",
768
+ "doctor",
769
+ `${relPath.replaceAll("/", "__")}.${timestampForBackup()}`
770
+ );
771
+ await mkdir(dirname(backupPath), { recursive: true });
772
+ await copyFile(pathValue, backupPath);
773
+ await writeFile(pathValue, after, "utf8");
774
+ repairedPaths.push(relPath);
775
+ backupPaths.push(backupPath);
776
+ }
777
+
778
+ return {
779
+ changed: repairedPaths.length > 0,
780
+ repairedPaths,
781
+ backupPaths,
782
+ };
783
+ }
784
+
600
785
  async function hasProjectToolRules(
601
786
  rootDir: string,
602
787
  tool: string
@@ -778,6 +963,7 @@ export async function buildDoctorReport(opts?: {
778
963
  writebackReviewDirExists,
779
964
  evolutionReviewDirExists,
780
965
  canonicalGlobalDocs,
966
+ canonicalTemplateRefs,
781
967
  projectSyncPlan,
782
968
  ] = await Promise.all([
783
969
  pathExists(rootDir),
@@ -788,6 +974,7 @@ export async function buildDoctorReport(opts?: {
788
974
  pathExists(writebackReviewDir),
789
975
  pathExists(evolutionReviewDir),
790
976
  inspectCanonicalGlobalDocs(rootDir),
977
+ inspectCanonicalTemplateRefs(rootDir),
791
978
  planProjectSyncPolicyRepair({ home, rootDir }),
792
979
  ]);
793
980
 
@@ -836,6 +1023,23 @@ export async function buildDoctorReport(opts?: {
836
1023
  });
837
1024
  }
838
1025
 
1026
+ for (const issue of canonicalTemplateRefs.issues) {
1027
+ issues.push({
1028
+ severity: "warning",
1029
+ code: issue.code,
1030
+ message: issue.message,
1031
+ fix: "Run fclt doctor --repair to resolve known refs into concrete paths, then review any remaining placeholders.",
1032
+ });
1033
+ }
1034
+ if (!canonicalTemplateRefs.valid) {
1035
+ actions.push({
1036
+ id: "repair-canonical-template-refs",
1037
+ label: "Repair unresolved canonical template refs",
1038
+ command: "fclt doctor --repair",
1039
+ risk: "canonical_write",
1040
+ });
1041
+ }
1042
+
839
1043
  if (generatedOnlyProjectRoot) {
840
1044
  issues.push({
841
1045
  severity: "error",
@@ -927,7 +1131,7 @@ export async function buildDoctorReport(opts?: {
927
1131
  state = "project_generated_only";
928
1132
  } else if (!(rootExists && canonicalSourceExists)) {
929
1133
  state = "uninitialized";
930
- } else if (!canonicalGlobalDocs.valid) {
1134
+ } else if (!(canonicalGlobalDocs.valid && canonicalTemplateRefs.valid)) {
931
1135
  state = "canonical_source_attention";
932
1136
  } else if (!(writebackReviewDirExists && evolutionReviewDirExists)) {
933
1137
  state = "partial_global_config";
@@ -971,6 +1175,13 @@ export async function buildDoctorReport(opts?: {
971
1175
  canonicalGlobalDocsIssueCodes: canonicalGlobalDocs.issues.map(
972
1176
  (issue) => issue.code
973
1177
  ),
1178
+ canonicalTemplateRefsValid: canonicalTemplateRefs.valid,
1179
+ canonicalTemplateRefsIssueCodes: canonicalTemplateRefs.issues.map(
1180
+ (issue) => issue.code
1181
+ ),
1182
+ canonicalTemplateRefsIssuePaths: canonicalTemplateRefs.issues.map(
1183
+ (issue) => issue.relPath
1184
+ ),
974
1185
  projectSyncRepairNeeded: projectSyncPlan.needed,
975
1186
  projectSyncRepairTools,
976
1187
  },
@@ -1029,6 +1240,7 @@ export async function doctorCommand(argv: string[]) {
1029
1240
  let codexAuthoringConflicts: string[] = [];
1030
1241
  let canonicalGlobalDocsRepaired = false;
1031
1242
  let canonicalGlobalDocsBackupPath: string | undefined;
1243
+ let canonicalTemplateRefsRepair: CanonicalTemplateRefsRepair | undefined;
1032
1244
  let reviewArtifactsRefreshed:
1033
1245
  | {
1034
1246
  writebackCount: number;
@@ -1061,6 +1273,7 @@ export async function doctorCommand(argv: string[]) {
1061
1273
  });
1062
1274
  canonicalGlobalDocsRepaired = globalDocsRepair.changed;
1063
1275
  canonicalGlobalDocsBackupPath = globalDocsRepair.backupPath;
1276
+ canonicalTemplateRefsRepair = await repairCanonicalTemplateRefs(rootDir);
1064
1277
  const { refreshAiReviewArtifacts } = await import("./ai");
1065
1278
  reviewArtifactsRefreshed = await refreshAiReviewArtifacts({
1066
1279
  homeDir: home,
@@ -1132,6 +1345,12 @@ export async function doctorCommand(argv: string[]) {
1132
1345
  `Repaired canonical AGENTS.global.md from the built-in operating model. Backup: ${canonicalGlobalDocsBackupPath}`
1133
1346
  );
1134
1347
  }
1348
+ if (canonicalTemplateRefsRepair?.changed) {
1349
+ console.log("Resolved canonical template refs in:");
1350
+ for (const repairedPath of canonicalTemplateRefsRepair.repairedPaths) {
1351
+ console.log(`- ${repairedPath}`);
1352
+ }
1353
+ }
1135
1354
  if (reviewArtifactsRefreshed) {
1136
1355
  console.log(
1137
1356
  `Refreshed AI review artifacts: ${reviewArtifactsRefreshed.writebackCount} writebacks in ${reviewArtifactsRefreshed.writebackReviewDir}, ${reviewArtifactsRefreshed.proposalCount} proposals in ${reviewArtifactsRefreshed.evolutionReviewDir}.`
@@ -1157,6 +1376,18 @@ export async function doctorCommand(argv: string[]) {
1157
1376
  return;
1158
1377
  }
1159
1378
  }
1379
+ const canonicalTemplateRefs = await inspectCanonicalTemplateRefs(rootDir);
1380
+ if (!canonicalTemplateRefs.valid) {
1381
+ for (const issue of canonicalTemplateRefs.issues) {
1382
+ console.log(
1383
+ `${issue.message} Run \`fclt doctor --repair\` to resolve known refs into concrete paths, then review any remaining placeholders.`
1384
+ );
1385
+ }
1386
+ if (!repair) {
1387
+ process.exitCode = 1;
1388
+ return;
1389
+ }
1390
+ }
1160
1391
  if (await isGeneratedOnlyProjectRoot({ home, rootDir })) {
1161
1392
  console.log(
1162
1393
  "Project .ai root contains generated state only. Canonical project source is missing, so managed project sync should be treated as unsafe until source is initialized, restored, or management is detached."
@@ -228,18 +228,25 @@ async function renderSourceTarget(args: {
228
228
  tool: string;
229
229
  }): Promise<string> {
230
230
  const raw = await Bun.file(args.sourcePath).text();
231
+ const builtinRoot = facultBuiltinPackRoot();
232
+ const sourceRoot = args.sourcePath.startsWith(`${builtinRoot}/`)
233
+ ? builtinRoot
234
+ : args.rootDir;
231
235
  const withSnippets = await renderSnippetText({
232
236
  text: raw,
233
237
  filePath: args.sourcePath,
234
- rootDir: args.rootDir,
238
+ rootDir: sourceRoot,
235
239
  });
236
240
  if (withSnippets.errors.length) {
237
241
  throw new Error(withSnippets.errors.join("\n"));
238
242
  }
239
243
  return await renderCanonicalText(withSnippets.text, {
240
244
  homeDir: args.homeDir,
241
- rootDir: args.rootDir,
242
- projectRoot: projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined,
245
+ rootDir: sourceRoot,
246
+ projectRoot:
247
+ sourceRoot === args.rootDir
248
+ ? (projectRootFromAiRoot(args.rootDir, args.homeDir) ?? undefined)
249
+ : undefined,
243
250
  targetTool: args.tool,
244
251
  targetPath: args.targetPath,
245
252
  });
package/src/remote.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdir, readdir, readFile, rm } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import {
@@ -130,6 +131,7 @@ interface InstallResult {
130
131
  sourceTrustLevel: SourceTrustLevel;
131
132
  dryRun: boolean;
132
133
  changedPaths: string[];
134
+ skippedPaths?: string[];
133
135
  }
134
136
 
135
137
  interface UpdateCheckResult {
@@ -394,17 +396,12 @@ Ship reliable changes quickly while keeping behavior predictable.
394
396
  type: "snippet",
395
397
  title: "Snippet Template",
396
398
  description:
397
- "Reusable snippet block template for coding standards and communication style.",
399
+ "Reusable snippet block template for a compact quality checklist.",
398
400
  version: "1.0.0",
399
401
  tags: ["template", "dx", "snippet"],
400
402
  snippet: {
401
- marker: "team/codingstyle",
402
- content: `## Coding Style
403
- - Prefer explicit, descriptive names over abbreviations.
404
- - Keep functions focused and side-effect boundaries obvious.
405
- - Add tests when behavior changes.
406
-
407
- ## Review Checklist
403
+ marker: "team/quality-checklist",
404
+ content: `## Quality Checklist
408
405
  - Is behavior correct for edge cases?
409
406
  - Are failure modes clear and actionable?
410
407
  - Is the change minimal for the goal?
@@ -1303,17 +1300,64 @@ async function listFilesRecursive(rootDir: string): Promise<string[]> {
1303
1300
  return out.sort();
1304
1301
  }
1305
1302
 
1303
+ interface BuiltinPackManifest {
1304
+ version: 1;
1305
+ pack: string;
1306
+ updatedAt: string;
1307
+ files: Record<string, { sha256: string }>;
1308
+ }
1309
+
1310
+ function sha256Text(value: string): string {
1311
+ return createHash("sha256").update(value).digest("hex");
1312
+ }
1313
+
1314
+ function builtinPackManifestPath(rootDir: string): string {
1315
+ return join(rootDir, ".facult", "packs", "facult-operating-model.json");
1316
+ }
1317
+
1318
+ async function readBuiltinPackManifest(
1319
+ rootDir: string
1320
+ ): Promise<BuiltinPackManifest | null> {
1321
+ const pathValue = builtinPackManifestPath(rootDir);
1322
+ if (!(await pathExists(pathValue))) {
1323
+ return null;
1324
+ }
1325
+ try {
1326
+ const parsed = JSON.parse(await Bun.file(pathValue).text());
1327
+ if (
1328
+ parsed?.version === 1 &&
1329
+ parsed.pack === "facult-operating-model" &&
1330
+ isPlainObject(parsed.files)
1331
+ ) {
1332
+ return parsed as BuiltinPackManifest;
1333
+ }
1334
+ } catch {
1335
+ return null;
1336
+ }
1337
+ return null;
1338
+ }
1339
+
1340
+ function serializeBuiltinPackManifest(manifest: BuiltinPackManifest): string {
1341
+ return `${JSON.stringify(manifest, null, 2)}\n`;
1342
+ }
1343
+
1306
1344
  async function scaffoldBuiltinOperatingModelPack(args: {
1307
1345
  rootDir: string;
1308
1346
  homeDir?: string;
1309
1347
  dryRun?: boolean;
1310
1348
  force?: boolean;
1349
+ update?: boolean;
1311
1350
  installedAs?: string;
1312
1351
  }): Promise<InstallResult> {
1313
1352
  const rootDir = resolve(args.rootDir);
1314
1353
  const packRoot = facultBuiltinPackRoot("facult-operating-model");
1315
1354
  const files = await listFilesRecursive(packRoot);
1316
1355
  const changedPaths: string[] = [];
1356
+ const skippedPaths: string[] = [];
1357
+ const existingManifest = await readBuiltinPackManifest(rootDir);
1358
+ const manifestFiles: BuiltinPackManifest["files"] = {
1359
+ ...(existingManifest?.files ?? {}),
1360
+ };
1317
1361
 
1318
1362
  for (const sourcePath of files) {
1319
1363
  const relPath = relative(packRoot, sourcePath);
@@ -1321,23 +1365,90 @@ async function scaffoldBuiltinOperatingModelPack(args: {
1321
1365
  continue;
1322
1366
  }
1323
1367
  const targetPath = join(rootDir, relPath);
1368
+ const sourceText = await Bun.file(sourcePath).text();
1369
+ const sourceHash = sha256Text(sourceText);
1324
1370
  const exists = await pathExists(targetPath);
1325
- if (exists && !args.force) {
1371
+ let shouldWrite = !exists || Boolean(args.force);
1372
+
1373
+ if (exists && !shouldWrite) {
1374
+ const targetText = await Bun.file(targetPath).text();
1375
+ const targetHash = sha256Text(targetText);
1376
+ if (targetHash === sourceHash) {
1377
+ manifestFiles[relPath] = { sha256: sourceHash };
1378
+ } else if (
1379
+ args.update &&
1380
+ existingManifest?.files[relPath]?.sha256 === targetHash
1381
+ ) {
1382
+ shouldWrite = true;
1383
+ } else if (args.update) {
1384
+ skippedPaths.push(targetPath);
1385
+ }
1386
+ }
1387
+
1388
+ if (!shouldWrite) {
1326
1389
  continue;
1327
1390
  }
1328
1391
  changedPaths.push(targetPath);
1392
+ manifestFiles[relPath] = { sha256: sourceHash };
1329
1393
  if (!args.dryRun) {
1330
1394
  await mkdir(dirname(targetPath), { recursive: true });
1331
- await Bun.write(targetPath, await Bun.file(sourcePath).text());
1395
+ await Bun.write(targetPath, sourceText);
1332
1396
  }
1333
1397
  }
1334
1398
 
1335
1399
  const configPath = join(rootDir, "config.toml");
1336
- if (!(await pathExists(configPath)) || args.force) {
1400
+ const configRelPath = "config.toml";
1401
+ const configText = "version = 1\n";
1402
+ const configHash = sha256Text(configText);
1403
+ const configExists = await pathExists(configPath);
1404
+ let shouldWriteConfig = !configExists || Boolean(args.force);
1405
+ if (configExists && !shouldWriteConfig) {
1406
+ const targetText = await Bun.file(configPath).text();
1407
+ const targetHash = sha256Text(targetText);
1408
+ if (targetHash === configHash) {
1409
+ manifestFiles[configRelPath] = { sha256: configHash };
1410
+ } else if (
1411
+ args.update &&
1412
+ existingManifest?.files[configRelPath]?.sha256 === targetHash
1413
+ ) {
1414
+ shouldWriteConfig = true;
1415
+ } else if (args.update) {
1416
+ skippedPaths.push(configPath);
1417
+ }
1418
+ }
1419
+ if (shouldWriteConfig) {
1337
1420
  changedPaths.push(configPath);
1421
+ manifestFiles[configRelPath] = { sha256: configHash };
1338
1422
  if (!args.dryRun) {
1339
1423
  await mkdir(dirname(configPath), { recursive: true });
1340
- await Bun.write(configPath, "version = 1\n");
1424
+ await Bun.write(configPath, configText);
1425
+ }
1426
+ }
1427
+
1428
+ const manifestPath = builtinPackManifestPath(rootDir);
1429
+ const sortedManifestFiles = Object.fromEntries(
1430
+ Object.entries(manifestFiles).sort(([a], [b]) => a.localeCompare(b))
1431
+ );
1432
+ const stableManifest = serializeBuiltinPackManifest({
1433
+ version: 1,
1434
+ pack: "facult-operating-model",
1435
+ updatedAt: existingManifest?.updatedAt ?? "",
1436
+ files: sortedManifestFiles,
1437
+ });
1438
+ const existingManifestText = (await pathExists(manifestPath))
1439
+ ? await Bun.file(manifestPath).text()
1440
+ : null;
1441
+ if (existingManifestText !== stableManifest) {
1442
+ const nextManifest = serializeBuiltinPackManifest({
1443
+ version: 1,
1444
+ pack: "facult-operating-model",
1445
+ updatedAt: new Date().toISOString(),
1446
+ files: sortedManifestFiles,
1447
+ });
1448
+ changedPaths.push(manifestPath);
1449
+ if (!args.dryRun) {
1450
+ await mkdir(dirname(manifestPath), { recursive: true });
1451
+ await Bun.write(manifestPath, nextManifest);
1341
1452
  }
1342
1453
  }
1343
1454
 
@@ -1357,6 +1468,7 @@ async function scaffoldBuiltinOperatingModelPack(args: {
1357
1468
  sourceTrustLevel: "trusted",
1358
1469
  dryRun: Boolean(args.dryRun),
1359
1470
  changedPaths: uniqueSorted(changedPaths),
1471
+ skippedPaths: uniqueSorted(skippedPaths),
1360
1472
  };
1361
1473
  }
1362
1474
 
@@ -1365,6 +1477,7 @@ async function scaffoldBuiltinProjectAiPack(args: {
1365
1477
  homeDir?: string;
1366
1478
  dryRun?: boolean;
1367
1479
  force?: boolean;
1480
+ update?: boolean;
1368
1481
  }): Promise<InstallResult> {
1369
1482
  const cwd = resolve(args.cwd ?? process.cwd());
1370
1483
  return await scaffoldBuiltinOperatingModelPack({
@@ -1372,6 +1485,7 @@ async function scaffoldBuiltinProjectAiPack(args: {
1372
1485
  homeDir: args.homeDir,
1373
1486
  dryRun: args.dryRun,
1374
1487
  force: args.force,
1488
+ update: args.update,
1375
1489
  installedAs: "project-ai",
1376
1490
  });
1377
1491
  }
@@ -2820,9 +2934,11 @@ function printTemplatesHelp() {
2820
2934
  ),
2821
2935
  renderCode("fclt templates init agents [--force] [--dry-run]"),
2822
2936
  renderCode(
2823
- "fclt templates init operating-model [--global|--project|--root PATH] [--force] [--dry-run]"
2937
+ "fclt templates init operating-model [--global|--project|--root PATH] [--update] [--force] [--dry-run]"
2938
+ ),
2939
+ renderCode(
2940
+ "fclt templates init project-ai [--update] [--force] [--dry-run]"
2824
2941
  ),
2825
- renderCode("fclt templates init project-ai [--force] [--dry-run]"),
2826
2942
  renderCode(
2827
2943
  "fclt templates init automation <template-id> [--scope global|project|wide] [--name <name>] [--project-root <path>] [--cwds <path1,path2>] [--rrule <RRULE>] [--status PAUSED|ACTIVE] [--yes] [--dry-run]"
2828
2944
  ),
@@ -3327,6 +3443,7 @@ export async function templatesCommand(
3327
3443
  }
3328
3444
  const dryRun = args.includes("--dry-run");
3329
3445
  const force = args.includes("--force");
3446
+ const update = args.includes("--update");
3330
3447
  const json = args.includes("--json");
3331
3448
  const parsedArgs = parseTemplateInitArgs(args);
3332
3449
  const positional = parsedArgs.positional;
@@ -3338,6 +3455,7 @@ export async function templatesCommand(
3338
3455
  homeDir: ctx.homeDir,
3339
3456
  dryRun,
3340
3457
  force,
3458
+ update,
3341
3459
  });
3342
3460
  if (json) {
3343
3461
  console.log(JSON.stringify(result, null, 2));
@@ -3353,6 +3471,14 @@ export async function templatesCommand(
3353
3471
  title: "Changed Paths",
3354
3472
  lines: renderBullets(result.changedPaths),
3355
3473
  },
3474
+ ...(result.skippedPaths?.length
3475
+ ? [
3476
+ {
3477
+ title: "Skipped Local Edits",
3478
+ lines: renderBullets(result.skippedPaths),
3479
+ },
3480
+ ]
3481
+ : []),
3356
3482
  ],
3357
3483
  })
3358
3484
  );
@@ -3383,6 +3509,7 @@ export async function templatesCommand(
3383
3509
  homeDir: ctx.homeDir,
3384
3510
  dryRun,
3385
3511
  force,
3512
+ update,
3386
3513
  });
3387
3514
  if (json) {
3388
3515
  console.log(JSON.stringify(result, null, 2));
@@ -3398,6 +3525,14 @@ export async function templatesCommand(
3398
3525
  title: "Changed Paths",
3399
3526
  lines: renderBullets(result.changedPaths),
3400
3527
  },
3528
+ ...(result.skippedPaths?.length
3529
+ ? [
3530
+ {
3531
+ title: "Skipped Local Edits",
3532
+ lines: renderBullets(result.skippedPaths),
3533
+ },
3534
+ ]
3535
+ : []),
3401
3536
  ],
3402
3537
  })
3403
3538
  );
@@ -20,8 +20,8 @@ Usage:
20
20
  fclt snippets sync [--dry-run] [file...]
21
21
 
22
22
  Notes:
23
- - <name> is the snippet marker name (e.g. codingstyle, global/codingstyle, myproject/context)
24
- - Unscoped names (e.g. codingstyle) resolve to project snippet first (if in a git repo), then global.
23
+ - <name> is the snippet marker name (e.g. qualitychecklist, global/qualitychecklist, myproject/context)
24
+ - Unscoped names (e.g. qualitychecklist) resolve to project snippet first (if in a git repo), then global.
25
25
  `);
26
26
  }
27
27