facult 2.10.0 → 2.12.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.
package/src/remote.ts CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  relative,
10
10
  resolve,
11
11
  } from "node:path";
12
- import { fileURLToPath } from "node:url";
13
12
  import { isCancel, multiselect, select, text } from "@clack/prompts";
13
+ import { facultBuiltinPackRoot } from "./builtin";
14
+ import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
14
15
  import {
15
16
  renderBullets,
16
17
  renderCatalog,
@@ -42,6 +43,7 @@ import {
42
43
  type RemoteAgentItem,
43
44
  type RemoteIndexItem,
44
45
  type RemoteIndexManifest,
46
+ type RemoteInstructionItem,
45
47
  type RemoteItemType,
46
48
  type RemoteMcpItem,
47
49
  type RemoteSkillItem,
@@ -59,6 +61,8 @@ const QUERY_SPLIT_RE = /\s+/;
59
61
  const MD_EXT_RE = /\.md$/i;
60
62
  const FILE_EXT_RE = /\.[A-Za-z0-9]+$/;
61
63
  const TRAILING_SLASH_RE = /\/+$/;
64
+ const LEADING_SLASH_RE = /^\/+/;
65
+ const INSTRUCTION_TITLE_SPLIT_RE = /[-_.\s]+/;
62
66
  const PROMPT_PATH_SPLIT_RE = /[,\n]/;
63
67
  const GIT_WORKTREE_LINE_RE = /\r?\n/;
64
68
 
@@ -221,6 +225,52 @@ Use this skill when the task repeatedly follows a known workflow and you want co
221
225
  },
222
226
  },
223
227
  },
228
+ {
229
+ id: "instruction-template",
230
+ type: "instruction",
231
+ title: "Instruction Template",
232
+ description:
233
+ "Reusable markdown instruction scaffold with ref and snippet composition examples.",
234
+ version: "1.0.0",
235
+ tags: ["template", "dx", "instruction"],
236
+ instruction: {
237
+ name: "WORKFLOW.md",
238
+ content: `---
239
+ description: "{{name}} reusable instruction"
240
+ tags: [instruction, workflow]
241
+ ---
242
+
243
+ # {{title}}
244
+
245
+ Use this instruction when the task needs repeatable guidance that should be discoverable, targetable by writeback, and composable into agent docs.
246
+
247
+ ## Scope
248
+
249
+ - Applies to:
250
+ - Does not apply to:
251
+ - Project-specific overrides:
252
+
253
+ ## Guidance
254
+
255
+ - Keep the rule concrete enough that another agent can follow it without chat context.
256
+ - Link deeper reusable guidance with canonical refs such as \`@ai/instructions/VERIFICATION.md\` or \`@project/instructions/TESTING.md\`.
257
+ - Reuse stable partials with snippet markers when the same block appears in more than one rendered doc.
258
+
259
+ ## Composition
260
+
261
+ <!-- fclty:global/team/example -->
262
+ <!-- /fclty:global/team/example -->
263
+
264
+ ## Writeback Targeting
265
+
266
+ Record durable friction against this instruction with:
267
+
268
+ \`\`\`bash
269
+ fclt ai writeback add --kind missing_context --summary "<what was missing>" --asset instruction:{{assetName}}
270
+ \`\`\`
271
+ `,
272
+ },
273
+ },
224
274
  {
225
275
  id: "mcp-stdio-template",
226
276
  type: "mcp",
@@ -1221,11 +1271,6 @@ updated_at = ${timestamp}
1221
1271
  };
1222
1272
  }
1223
1273
 
1224
- function builtinPackRoot(packName: string): string {
1225
- const here = dirname(fileURLToPath(import.meta.url));
1226
- return join(here, "..", "assets", "packs", packName);
1227
- }
1228
-
1229
1274
  async function pathExists(pathValue: string): Promise<boolean> {
1230
1275
  try {
1231
1276
  await Bun.file(pathValue).stat();
@@ -1258,15 +1303,15 @@ async function listFilesRecursive(rootDir: string): Promise<string[]> {
1258
1303
  return out.sort();
1259
1304
  }
1260
1305
 
1261
- async function scaffoldBuiltinProjectAiPack(args: {
1262
- cwd?: string;
1306
+ async function scaffoldBuiltinOperatingModelPack(args: {
1307
+ rootDir: string;
1263
1308
  homeDir?: string;
1264
1309
  dryRun?: boolean;
1265
1310
  force?: boolean;
1311
+ installedAs?: string;
1266
1312
  }): Promise<InstallResult> {
1267
- const cwd = resolve(args.cwd ?? process.cwd());
1268
- const rootDir = join(cwd, ".ai");
1269
- const packRoot = builtinPackRoot("facult-operating-model");
1313
+ const rootDir = resolve(args.rootDir);
1314
+ const packRoot = facultBuiltinPackRoot("facult-operating-model");
1270
1315
  const files = await listFilesRecursive(packRoot);
1271
1316
  const changedPaths: string[] = [];
1272
1317
 
@@ -1307,7 +1352,7 @@ async function scaffoldBuiltinProjectAiPack(args: {
1307
1352
  return {
1308
1353
  ref: `${BUILTIN_INDEX_NAME}:facult-operating-model`,
1309
1354
  type: "skill",
1310
- installedAs: "project-ai",
1355
+ installedAs: args.installedAs ?? "operating-model",
1311
1356
  path: rootDir,
1312
1357
  sourceTrustLevel: "trusted",
1313
1358
  dryRun: Boolean(args.dryRun),
@@ -1315,6 +1360,22 @@ async function scaffoldBuiltinProjectAiPack(args: {
1315
1360
  };
1316
1361
  }
1317
1362
 
1363
+ async function scaffoldBuiltinProjectAiPack(args: {
1364
+ cwd?: string;
1365
+ homeDir?: string;
1366
+ dryRun?: boolean;
1367
+ force?: boolean;
1368
+ }): Promise<InstallResult> {
1369
+ const cwd = resolve(args.cwd ?? process.cwd());
1370
+ return await scaffoldBuiltinOperatingModelPack({
1371
+ rootDir: join(cwd, ".ai"),
1372
+ homeDir: args.homeDir,
1373
+ dryRun: args.dryRun,
1374
+ force: args.force,
1375
+ installedAs: "project-ai",
1376
+ });
1377
+ }
1378
+
1318
1379
  function compareVersions(a: string, b: string): number {
1319
1380
  const aTokens = (a.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
1320
1381
  const bTokens = (b.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase());
@@ -1449,7 +1510,8 @@ function parseIndexItem(raw: unknown): RemoteIndexItem | null {
1449
1510
  type !== "skill" &&
1450
1511
  type !== "mcp" &&
1451
1512
  type !== "agent" &&
1452
- type !== "snippet"
1513
+ type !== "snippet" &&
1514
+ type !== "instruction"
1453
1515
  ) {
1454
1516
  return null;
1455
1517
  }
@@ -1548,6 +1610,30 @@ function parseIndexItem(raw: unknown): RemoteIndexItem | null {
1548
1610
  };
1549
1611
  }
1550
1612
 
1613
+ if (type === "instruction") {
1614
+ const instructionRaw = obj.instruction;
1615
+ if (!isPlainObject(instructionRaw)) {
1616
+ return null;
1617
+ }
1618
+ const name =
1619
+ typeof instructionRaw.name === "string" ? instructionRaw.name.trim() : "";
1620
+ const content =
1621
+ typeof instructionRaw.content === "string" ? instructionRaw.content : "";
1622
+ if (!(name && content)) {
1623
+ return null;
1624
+ }
1625
+ return {
1626
+ id,
1627
+ type,
1628
+ title,
1629
+ description,
1630
+ version,
1631
+ sourceUrl,
1632
+ tags,
1633
+ instruction: { name, content },
1634
+ };
1635
+ }
1636
+
1551
1637
  const snippetRaw = obj.snippet;
1552
1638
  if (!isPlainObject(snippetRaw)) {
1553
1639
  return null;
@@ -1719,7 +1805,8 @@ async function loadInstalledState(
1719
1805
  type !== "skill" &&
1720
1806
  type !== "mcp" &&
1721
1807
  type !== "agent" &&
1722
- type !== "snippet"
1808
+ type !== "snippet" &&
1809
+ type !== "instruction"
1723
1810
  ) {
1724
1811
  continue;
1725
1812
  }
@@ -2023,6 +2110,69 @@ async function installSnippetItem(args: {
2023
2110
  };
2024
2111
  }
2025
2112
 
2113
+ function normalizeInstructionName(value: string): string {
2114
+ const normalized = value
2115
+ .trim()
2116
+ .replaceAll("\\", "/")
2117
+ .replace(LEADING_SLASH_RE, "");
2118
+ if (!normalized) {
2119
+ throw new Error("Instruction name is required");
2120
+ }
2121
+ const withExt = MD_EXT_RE.test(normalized) ? normalized : `${normalized}.md`;
2122
+ if (!isSafeRelativePath(withExt)) {
2123
+ throw new Error(`Invalid instruction name: ${value}`);
2124
+ }
2125
+ return withExt;
2126
+ }
2127
+
2128
+ function instructionTitleFromFileName(fileName: string): string {
2129
+ const base = basename(fileName).replace(MD_EXT_RE, "");
2130
+ return base
2131
+ .split(INSTRUCTION_TITLE_SPLIT_RE)
2132
+ .filter(Boolean)
2133
+ .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
2134
+ .join(" ");
2135
+ }
2136
+
2137
+ function instructionAssetName(fileName: string): string {
2138
+ return basename(fileName).replace(MD_EXT_RE, "");
2139
+ }
2140
+
2141
+ async function installInstructionItem(args: {
2142
+ item: RemoteInstructionItem;
2143
+ installAs?: string;
2144
+ rootDir: string;
2145
+ force: boolean;
2146
+ dryRun: boolean;
2147
+ }): Promise<{ installedAs: string; path: string; changedPaths: string[] }> {
2148
+ const fileName = normalizeInstructionName(
2149
+ args.installAs ?? args.item.instruction.name
2150
+ );
2151
+ const instructionPath = join(args.rootDir, "instructions", fileName);
2152
+ assertInstallPath(instructionPath, join(args.rootDir, "instructions"));
2153
+ if ((await fileExists(instructionPath)) && !args.force) {
2154
+ throw new Error(
2155
+ `Instruction already exists: ${fileName} (use --force to overwrite)`
2156
+ );
2157
+ }
2158
+ if (!args.dryRun) {
2159
+ await mkdir(dirname(instructionPath), { recursive: true });
2160
+ await Bun.write(
2161
+ instructionPath,
2162
+ renderTemplate(args.item.instruction.content, {
2163
+ name: fileName,
2164
+ title: instructionTitleFromFileName(fileName),
2165
+ assetName: instructionAssetName(fileName),
2166
+ })
2167
+ );
2168
+ }
2169
+ return {
2170
+ installedAs: fileName,
2171
+ path: instructionPath,
2172
+ changedPaths: [instructionPath],
2173
+ };
2174
+ }
2175
+
2026
2176
  async function installParsedItem(args: {
2027
2177
  parsedRef: { index: string; itemId: string };
2028
2178
  item: RemoteIndexItem;
@@ -2064,7 +2214,7 @@ async function installParsedItem(args: {
2064
2214
  force: args.force,
2065
2215
  dryRun: args.dryRun,
2066
2216
  });
2067
- } else {
2217
+ } else if (args.item.type === "snippet") {
2068
2218
  writeResult = await installSnippetItem({
2069
2219
  item: args.item,
2070
2220
  installAs: args.installAs,
@@ -2072,6 +2222,14 @@ async function installParsedItem(args: {
2072
2222
  force: args.force,
2073
2223
  dryRun: args.dryRun,
2074
2224
  });
2225
+ } else {
2226
+ writeResult = await installInstructionItem({
2227
+ item: args.item,
2228
+ installAs: args.installAs,
2229
+ rootDir: args.rootDir,
2230
+ force: args.force,
2231
+ dryRun: args.dryRun,
2232
+ });
2075
2233
  }
2076
2234
 
2077
2235
  const result: InstallResult = {
@@ -2654,10 +2812,16 @@ function printTemplatesHelp() {
2654
2812
  renderCode(
2655
2813
  "fclt templates init agent <name> [--force] [--dry-run]"
2656
2814
  ),
2815
+ renderCode(
2816
+ "fclt templates init instruction <name> [--force] [--dry-run]"
2817
+ ),
2657
2818
  renderCode(
2658
2819
  "fclt templates init snippet <marker> [--force] [--dry-run]"
2659
2820
  ),
2660
2821
  renderCode("fclt templates init agents [--force] [--dry-run]"),
2822
+ renderCode(
2823
+ "fclt templates init operating-model [--global|--project|--root PATH] [--force] [--dry-run]"
2824
+ ),
2661
2825
  renderCode("fclt templates init project-ai [--force] [--dry-run]"),
2662
2826
  renderCode(
2663
2827
  "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]"
@@ -2719,6 +2883,53 @@ function parseLongFlag(argv: string[], flag: string): string | null {
2719
2883
  return null;
2720
2884
  }
2721
2885
 
2886
+ const TEMPLATE_INIT_VALUE_FLAGS = new Set([
2887
+ "--automation-status",
2888
+ "--cwds",
2889
+ "--name",
2890
+ "--project-root",
2891
+ "--root",
2892
+ "--rrule",
2893
+ "--scope",
2894
+ "--status",
2895
+ ]);
2896
+
2897
+ function parseTemplateInitArgs(argv: string[]): {
2898
+ positional: string[];
2899
+ rootArg?: string;
2900
+ } {
2901
+ const positional: string[] = [];
2902
+ let rootArg: string | undefined;
2903
+ for (let i = 0; i < argv.length; i += 1) {
2904
+ const arg = argv[i];
2905
+ if (!arg) {
2906
+ continue;
2907
+ }
2908
+ if (arg === "--root") {
2909
+ rootArg = argv[i + 1] ?? undefined;
2910
+ i += 1;
2911
+ continue;
2912
+ }
2913
+ if (arg.startsWith("--root=")) {
2914
+ rootArg = arg.slice("--root=".length);
2915
+ continue;
2916
+ }
2917
+ if (TEMPLATE_INIT_VALUE_FLAGS.has(arg)) {
2918
+ i += 1;
2919
+ continue;
2920
+ }
2921
+ const equalFlag = arg.startsWith("--") ? arg.split("=", 1)[0] : "";
2922
+ if (equalFlag && TEMPLATE_INIT_VALUE_FLAGS.has(equalFlag)) {
2923
+ continue;
2924
+ }
2925
+ if (arg.startsWith("-")) {
2926
+ continue;
2927
+ }
2928
+ positional.push(arg);
2929
+ }
2930
+ return { positional, rootArg };
2931
+ }
2932
+
2722
2933
  export async function sourcesCommand(
2723
2934
  argv: string[],
2724
2935
  ctx: RemoteCommandContext = {}
@@ -3052,6 +3263,14 @@ export async function templatesCommand(
3052
3263
  description: item.description ?? "",
3053
3264
  version: item.version ?? "",
3054
3265
  })),
3266
+ {
3267
+ id: "operating-model",
3268
+ type: "pack",
3269
+ title: "Operating Model Pack",
3270
+ description:
3271
+ "Install the built-in Facult operating-model pack into the active canonical root.",
3272
+ version: "1.0.0",
3273
+ },
3055
3274
  {
3056
3275
  id: "project-ai",
3057
3276
  type: "pack",
@@ -3101,7 +3320,7 @@ export async function templatesCommand(
3101
3320
  const [kind, ...args] = rest;
3102
3321
  if (!kind) {
3103
3322
  console.error(
3104
- "templates init requires a kind (skill|mcp|agent|snippet|agents|claude|project-ai|automation)"
3323
+ "templates init requires a kind (skill|mcp|agent|instruction|snippet|agents|claude|operating-model|project-ai|automation)"
3105
3324
  );
3106
3325
  process.exitCode = 2;
3107
3326
  return;
@@ -3109,7 +3328,8 @@ export async function templatesCommand(
3109
3328
  const dryRun = args.includes("--dry-run");
3110
3329
  const force = args.includes("--force");
3111
3330
  const json = args.includes("--json");
3112
- const positional = args.filter((a) => a && !a.startsWith("-"));
3331
+ const parsedArgs = parseTemplateInitArgs(args);
3332
+ const positional = parsedArgs.positional;
3113
3333
 
3114
3334
  if (kind === "project-ai") {
3115
3335
  try {
@@ -3144,6 +3364,51 @@ export async function templatesCommand(
3144
3364
  }
3145
3365
  }
3146
3366
 
3367
+ if (kind === "operating-model") {
3368
+ try {
3369
+ const context = parseCliContextArgs(args, { allowScope: false });
3370
+ const cwd = resolve(ctx.cwd ?? process.cwd());
3371
+ const rootDir = ctx.rootDir
3372
+ ? resolve(ctx.rootDir)
3373
+ : context.scope === "project" && !context.rootArg
3374
+ ? join(findGitRootFromPath(cwd) ?? cwd, ".ai")
3375
+ : resolveCliContextRoot({
3376
+ rootArg: context.rootArg,
3377
+ scope: context.scope,
3378
+ homeDir: ctx.homeDir,
3379
+ cwd,
3380
+ });
3381
+ const result = await scaffoldBuiltinOperatingModelPack({
3382
+ rootDir,
3383
+ homeDir: ctx.homeDir,
3384
+ dryRun,
3385
+ force,
3386
+ });
3387
+ if (json) {
3388
+ console.log(JSON.stringify(result, null, 2));
3389
+ return;
3390
+ }
3391
+ const action = dryRun ? "Would install" : "Installed";
3392
+ console.log(
3393
+ renderPage({
3394
+ title: `fclt templates init ${kind}`,
3395
+ subtitle: `${action} ${result.installedAs} into ${result.path}`,
3396
+ sections: [
3397
+ {
3398
+ title: "Changed Paths",
3399
+ lines: renderBullets(result.changedPaths),
3400
+ },
3401
+ ],
3402
+ })
3403
+ );
3404
+ return;
3405
+ } catch (err) {
3406
+ console.error(err instanceof Error ? err.message : String(err));
3407
+ process.exitCode = 1;
3408
+ return;
3409
+ }
3410
+ }
3411
+
3147
3412
  let ref = "";
3148
3413
  let as: string | undefined;
3149
3414
  if (kind === "skill") {
@@ -3176,6 +3441,14 @@ export async function templatesCommand(
3176
3441
  as = normalizedName.endsWith(".toml")
3177
3442
  ? normalizedName
3178
3443
  : `${normalizedName}/agent.toml`;
3444
+ } else if (kind === "instruction") {
3445
+ ref = `${BUILTIN_INDEX_NAME}:instruction-template`;
3446
+ as = positional[0];
3447
+ if (!as) {
3448
+ console.error("templates init instruction requires a <name>");
3449
+ process.exitCode = 2;
3450
+ return;
3451
+ }
3179
3452
  } else if (kind === "snippet") {
3180
3453
  ref = `${BUILTIN_INDEX_NAME}:snippet-template`;
3181
3454
  as = positional[0];
@@ -3285,7 +3558,7 @@ export async function templatesCommand(
3285
3558
  dryRun,
3286
3559
  force,
3287
3560
  homeDir: ctx.homeDir,
3288
- rootDir: ctx.rootDir,
3561
+ rootDir: parsedArgs.rootArg ?? ctx.rootDir,
3289
3562
  cwd: ctx.cwd,
3290
3563
  fetchJson: ctx.fetchJson,
3291
3564
  fetchText: ctx.fetchText,