rulesync 16.20.0 → 16.21.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.
@@ -26,6 +26,7 @@ let node_fs_promises = require("node:fs/promises");
26
26
  let node_path = require("node:path");
27
27
  node_path = __toESM(node_path, 1);
28
28
  let jsonc_parser = require("jsonc-parser");
29
+ let node_fs = require("node:fs");
29
30
  let node_os = require("node:os");
30
31
  node_os = __toESM(node_os, 1);
31
32
  let es_toolkit = require("es-toolkit");
@@ -36,7 +37,6 @@ let gray_matter = require("gray-matter");
36
37
  gray_matter = __toESM(gray_matter, 1);
37
38
  let js_yaml = require("js-yaml");
38
39
  let es_toolkit_object = require("es-toolkit/object");
39
- let node_fs = require("node:fs");
40
40
  let node_crypto = require("node:crypto");
41
41
  let smol_toml = require("smol-toml");
42
42
  smol_toml = __toESM(smol_toml, 1);
@@ -63,6 +63,26 @@ function stripControlCharacters(text) {
63
63
  return text.replace(CONTROL_CHARACTERS_PATTERN, "");
64
64
  }
65
65
  /**
66
+ * Strips the control characters from `text` and then JSON-quotes it, which is
67
+ * the form a remote-derived name — a fetched path, a directory name read off
68
+ * the disk, a branch name chosen by a remote repository — takes in a log line.
69
+ *
70
+ * The two steps do different jobs, and neither is enough on its own. The
71
+ * quoting delimits the untrusted text, so a reader can tell where the name
72
+ * ends and the diagnostic resumes, and it escapes the quotes and backslashes
73
+ * that would blur that edge. But `JSON.stringify` escapes the C0 controls and
74
+ * nothing else: the C1 range, the 8-bit CSI introducer among it, and the bidi
75
+ * overrides pass through it intact, and any one of them reaches the terminal
76
+ * with its power to forge a line or reorder one. The strip is what takes those
77
+ * out. It runs first so that the quoting sees only printable text and the line
78
+ * carries no escaped control characters the reader has to decode — what could
79
+ * do harm is gone rather than spelled out — and so that the rationale for the
80
+ * order lives here, once, rather than at every call site.
81
+ */
82
+ function quoteForLog(text) {
83
+ return JSON.stringify(stripControlCharacters(text));
84
+ }
85
+ /**
66
86
  * Removes every control character from `text` except the line feed, so a
67
87
  * message written to be read over several lines still is.
68
88
  *
@@ -395,34 +415,34 @@ const isFeatureValueEnabled = (value) => {
395
415
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
396
416
  //#endregion
397
417
  //#region src/constants/rulesync-paths.ts
398
- const { join: join$298 } = node_path.posix;
418
+ const { join: join$299 } = node_path.posix;
399
419
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
400
420
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
401
421
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
402
422
  const RULES_FEATURE_SUBDIR = "rules";
403
- const CURATED_RULES_FEATURE_SUBDIR = join$298(RULES_FEATURE_SUBDIR, ".curated");
423
+ const CURATED_RULES_FEATURE_SUBDIR = join$299(RULES_FEATURE_SUBDIR, ".curated");
404
424
  const COMMANDS_FEATURE_SUBDIR = "commands";
405
425
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
406
426
  const CHECKS_FEATURE_SUBDIR = "checks";
407
427
  const SKILLS_FEATURE_SUBDIR = "skills";
408
- const CURATED_SKILLS_FEATURE_SUBDIR = join$298(SKILLS_FEATURE_SUBDIR, ".curated");
409
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
410
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
411
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
412
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
413
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
414
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
415
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
416
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
417
- join$298(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
418
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
419
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
428
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$299(SKILLS_FEATURE_SUBDIR, ".curated");
429
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
430
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
431
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
432
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
433
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
434
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
435
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
436
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
437
+ join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
438
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
439
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
420
440
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
421
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
441
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
422
442
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
423
443
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
424
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
425
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
444
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
445
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
426
446
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
427
447
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
428
448
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -928,9 +948,9 @@ function toPosixPath(p) {
928
948
  return p.replace(/\\/g, "/");
929
949
  }
930
950
  function checkPathTraversal({ relativePath, intendedRootDir }) {
931
- if (relativePath.split(/[/\\]/).includes("..")) throw new Error(`Path traversal detected: ${JSON.stringify(stripControlCharacters(relativePath))}`);
951
+ if (relativePath.split(/[/\\]/).includes("..")) throw new Error(`Path traversal detected: ${quoteForLog(relativePath)}`);
932
952
  const resolved = (0, node_path.resolve)(intendedRootDir, relativePath);
933
- if ((0, node_path.relative)(intendedRootDir, resolved).startsWith("..") || (0, node_path.resolve)(resolved) !== resolved) throw new Error(`Path traversal detected: ${JSON.stringify(stripControlCharacters(relativePath))}`);
953
+ if ((0, node_path.relative)(intendedRootDir, resolved).startsWith("..") || (0, node_path.resolve)(resolved) !== resolved) throw new Error(`Path traversal detected: ${quoteForLog(relativePath)}`);
934
954
  }
935
955
  /**
936
956
  * Resolves a path relative to a base directory, handling both absolute and relative paths
@@ -990,24 +1010,60 @@ async function writeFileContent(filepath, content) {
990
1010
  * Apply a POSIX mode to an existing file. Windows has no executable bit and
991
1011
  * `chmod` there only toggles the read-only flag, so the call is skipped rather
992
1012
  * than writing a mode the platform cannot honor.
1013
+ *
1014
+ * The mode goes through a handle opened with `O_NOFOLLOW`, so a symbolic link
1015
+ * standing where the file should be is left alone rather than having the mode
1016
+ * land on whatever it points at -- possibly outside the output tree -- and the
1017
+ * file whose mode changes is the very one that was opened, with no path to
1018
+ * swap between the check and the `chmod`. The write side refuses to read
1019
+ * through links; the mode side refuses to write through them. A link is
1020
+ * skipped silently, like Windows; a file that does not exist still throws.
993
1021
  */
994
1022
  async function applyFileMode(filepath, mode) {
995
1023
  if (process.platform === "win32") return;
996
- await (0, node_fs_promises.chmod)(filepath, mode);
1024
+ const fileHandle = await openNotFollowingLinks(filepath);
1025
+ if (fileHandle === void 0) return;
1026
+ try {
1027
+ await fileHandle.chmod(mode);
1028
+ } finally {
1029
+ await fileHandle.close();
1030
+ }
997
1031
  }
998
1032
  /**
999
1033
  * Restore an executable bit that went missing (interrupted run, a copy that
1000
1034
  * dropped the mode). A file whose mode is merely stricter than `mode` — the
1001
- * user chose 0700 over 0755 — is left alone.
1035
+ * user chose 0700 over 0755 — is left alone, and so is a symbolic link or a
1036
+ * file that cannot be opened: this repairs, it never creates.
1002
1037
  */
1003
1038
  async function restoreMissingExecutableBit(filepath, mode) {
1004
1039
  if (process.platform === "win32") return;
1040
+ let fileHandle;
1005
1041
  try {
1006
- if (((await (0, node_fs_promises.stat)(filepath)).mode & 73) !== 0) return;
1042
+ fileHandle = await openNotFollowingLinks(filepath);
1007
1043
  } catch {
1008
1044
  return;
1009
1045
  }
1010
- await (0, node_fs_promises.chmod)(filepath, mode);
1046
+ if (fileHandle === void 0) return;
1047
+ try {
1048
+ if (((await fileHandle.stat()).mode & 73) !== 0) return;
1049
+ await fileHandle.chmod(mode);
1050
+ } finally {
1051
+ await fileHandle.close();
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Open a file for its mode without following a symbolic link at the path.
1056
+ * Returns `undefined` when the path is a link (`O_NOFOLLOW` fails with
1057
+ * `ELOOP`, or `EMLINK` on some BSDs); any other failure is the caller's.
1058
+ */
1059
+ async function openNotFollowingLinks(filepath) {
1060
+ try {
1061
+ return await (0, node_fs_promises.open)(filepath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
1062
+ } catch (error) {
1063
+ const code = error.code;
1064
+ if (code === "ELOOP" || code === "EMLINK") return;
1065
+ throw error;
1066
+ }
1011
1067
  }
1012
1068
  async function writeFileBuffer(filepath, buffer) {
1013
1069
  await ensureDir((0, node_path.dirname)(filepath));
@@ -1218,6 +1274,19 @@ function posixRelativePathEscapesRoot(relativePath) {
1218
1274
  return relativePath === ".." || relativePath.startsWith("../") || node_path.posix.isAbsolute(relativePath);
1219
1275
  }
1220
1276
  /**
1277
+ * Whether the file `targetPath` really denotes sits outside `rootPath`. Unlike
1278
+ * `pathEscapesRoot`, which reads a path as it is spelled, this resolves every link
1279
+ * on both sides first, so a name that sits inside the root but is a link pointing
1280
+ * out of it is reported as the escape it is. Both sides fall back to their literal
1281
+ * path when they cannot be resolved, which reports an escape rather than hiding one.
1282
+ */
1283
+ async function resolvedPathEscapesRoot({ rootPath, targetPath }) {
1284
+ return posixRelativePathEscapesRoot(await resolvedRelativePath({
1285
+ rootPath,
1286
+ targetPath
1287
+ }));
1288
+ }
1289
+ /**
1221
1290
  * How many trailing segments `filePath` and `identity` have in common.
1222
1291
  *
1223
1292
  * A path that walked through no link at all shares all of its own segments with
@@ -1227,9 +1296,17 @@ function posixRelativePathEscapesRoot(relativePath) {
1227
1296
  * what lets the comparison hold for a path that is not itself resolved: a glob
1228
1297
  * rooted at a directory that is a link of its own gives every candidate the
1229
1298
  * same unresolved prefix, and only the segments below it decide.
1299
+ *
1300
+ * `filePath` is spelled the way the platform reported it and `identity` is
1301
+ * posix-separated, so the two are split on both separators: on Windows a
1302
+ * backslash-separated path then shares its segments with the posix identity
1303
+ * without being rewritten first, and on a posix platform a backslash inside a
1304
+ * name is split the same way on both sides, which keeps the count symmetric.
1305
+ *
1306
+ * Exported for its tests, which need a Windows-style spelling on any platform.
1230
1307
  */
1231
1308
  function sharedTrailingSegments(filePath, identity) {
1232
- const left = splitPathSegments(nativePathToPosix(filePath));
1309
+ const left = splitPathSegments(filePath);
1233
1310
  const right = splitPathSegments(identity);
1234
1311
  let shared = 0;
1235
1312
  while (shared < left.length && shared < right.length && left[left.length - 1 - shared] === right[right.length - 1 - shared]) shared++;
@@ -1257,7 +1334,7 @@ function chooseRepresentative(candidates, identity) {
1257
1334
  });
1258
1335
  }
1259
1336
  async function findFilesByGlobs(globs, options = {}) {
1260
- const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
1337
+ const { cwd, type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
1261
1338
  const globbyOptions = type === "file" ? {
1262
1339
  onlyFiles: true,
1263
1340
  onlyDirectories: false
@@ -1271,6 +1348,7 @@ async function findFilesByGlobs(globs, options = {}) {
1271
1348
  const normalizedGlobs = Array.isArray(globs) ? globs.map((g) => g.replaceAll("\\", "/")) : globs.replaceAll("\\", "/");
1272
1349
  const results = (0, globby.globbySync)(normalizedGlobs, {
1273
1350
  absolute: true,
1351
+ cwd,
1274
1352
  followSymbolicLinks,
1275
1353
  dot,
1276
1354
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
@@ -1343,14 +1421,16 @@ async function listEntryNames(params) {
1343
1421
  */
1344
1422
  async function dedupeNamesByFileIdentity(params) {
1345
1423
  const { dirPath, entries } = params;
1346
- const identities = await mapWithConcurrency({
1424
+ const identifiedEntries = await mapWithConcurrency({
1347
1425
  items: entries,
1348
1426
  limit: ENTRY_CLASSIFY_CONCURRENCY,
1349
- mapper: async (entry) => await realFileIdentity((0, node_path.join)(dirPath, entry.name))
1427
+ mapper: async (entry) => ({
1428
+ entry,
1429
+ identity: await realFileIdentity((0, node_path.join)(dirPath, entry.name))
1430
+ })
1350
1431
  });
1351
1432
  const entriesByIdentity = /* @__PURE__ */ new Map();
1352
- for (const [index, entry] of entries.entries()) {
1353
- const identity = identities[index] ?? nativePathToPosix((0, node_path.join)(dirPath, entry.name));
1433
+ for (const { entry, identity } of identifiedEntries) {
1354
1434
  const group = entriesByIdentity.get(identity);
1355
1435
  if (group === void 0) entriesByIdentity.set(identity, [entry]);
1356
1436
  else group.push(entry);
@@ -1649,43 +1729,76 @@ var CLIError = class extends Error {
1649
1729
  };
1650
1730
  //#endregion
1651
1731
  //#region src/utils/warned-once.ts
1732
+ function createRunWarningState() {
1733
+ return {
1734
+ messages: /* @__PURE__ */ new Set(),
1735
+ carriedFilesIncomplete: false
1736
+ };
1737
+ }
1652
1738
  /**
1653
- * The messages a once-per-run warning has already emitted in this process.
1739
+ * The state of a run that opened no scope of its own.
1654
1740
  * This lives in its own module, importing nothing of rulesync's, so the vitest
1655
1741
  * setup file can clear it between tests without pulling `logger.js` into every
1656
1742
  * test's module graph (which would defeat the module mocks some of those tests
1657
1743
  * install).
1658
1744
  */
1659
- const processWideMessages = /* @__PURE__ */ new Set();
1745
+ const processWideState = createRunWarningState();
1660
1746
  /**
1661
- * The set an operation that opened its own scope uses instead.
1747
+ * The state an operation that opened its own scope uses instead.
1662
1748
  *
1663
1749
  * The MCP server does not serialize requests, so two runs can be in flight at
1664
1750
  * once. Sharing one set between them would let the first run spend the token
1665
1751
  * for a message and leave the second one's result silent about a diagnostic
1666
1752
  * that applies to it just as much. A scope gives each run its own bookkeeping.
1667
1753
  */
1668
- const scopedMessages = new node_async_hooks.AsyncLocalStorage();
1669
- function currentMessages() {
1670
- return scopedMessages.getStore() ?? processWideMessages;
1754
+ const scopedState = new node_async_hooks.AsyncLocalStorage();
1755
+ function currentState() {
1756
+ return scopedState.getStore() ?? processWideState;
1671
1757
  }
1672
1758
  /** Whether `message` has not been emitted yet; records it when it has not. */
1673
1759
  function claimWarnOnce(message) {
1674
- const messages = currentMessages();
1760
+ const { messages } = currentState();
1675
1761
  if (messages.has(message)) return false;
1676
1762
  messages.add(message);
1677
1763
  return true;
1678
1764
  }
1679
- /** Forget which warnings were already emitted, so the next run starts silent. */
1680
- function resetWarnedOnceMessages() {
1681
- currentMessages().clear();
1765
+ /**
1766
+ * Record that this run could not read everything a directory carries -- a file
1767
+ * it could not open, a walk that hit one of its bounds, a subtree it was denied.
1768
+ *
1769
+ * Kept here, beside the once-per-run messages, because it shares their lifetime
1770
+ * exactly: it is set at the moment such a shortfall is warned about, and cleared
1771
+ * when the next run resets its warnings.
1772
+ *
1773
+ * Deliberate refusals are not shortfalls. A hidden entry, a credential-shaped
1774
+ * name, a link into a pseudo-filesystem: those are files Rulesync never carries,
1775
+ * on every run, and a run that leaves them out has read its source in full.
1776
+ */
1777
+ function recordIncompleteCarriedFiles() {
1778
+ currentState().carriedFilesIncomplete = true;
1779
+ }
1780
+ /**
1781
+ * Whether {@link recordIncompleteCarriedFiles} fired in this run.
1782
+ *
1783
+ * A caller that deletes what a run did not write has to ask: a run holding an
1784
+ * incomplete picture of its source cannot tell a stale file from one whose
1785
+ * source it merely failed to read.
1786
+ */
1787
+ function hasIncompleteCarriedFiles() {
1788
+ return currentState().carriedFilesIncomplete;
1789
+ }
1790
+ /** Forget what was already reported, so the next run starts silent. */
1791
+ function resetRunWarningState() {
1792
+ const state = currentState();
1793
+ state.messages.clear();
1794
+ state.carriedFilesIncomplete = false;
1682
1795
  }
1683
1796
  /**
1684
1797
  * Run `operation` with its own once-per-run bookkeeping, so a concurrent run
1685
1798
  * neither spends its tokens nor clears its record.
1686
1799
  */
1687
1800
  async function withWarnOnceScope(operation) {
1688
- return await scopedMessages.run(/* @__PURE__ */ new Set(), operation);
1801
+ return await scopedState.run(createRunWarningState(), operation);
1689
1802
  }
1690
1803
  //#endregion
1691
1804
  //#region src/utils/logger.ts
@@ -2059,6 +2172,148 @@ var WarningCollectingLogger = class extends ConsoleLogger {
2059
2172
  }
2060
2173
  };
2061
2174
  //#endregion
2175
+ //#region src/types/language.ts
2176
+ /**
2177
+ * Response languages the root `language` key of `rulesync.jsonc` accepts.
2178
+ *
2179
+ * BCP 47-style codes: a bare ISO 639-1 code where one language name is
2180
+ * unambiguous, and a region-qualified code where the written form differs by
2181
+ * region (`zh-CN` / `zh-TW`, `pt-BR`). Widening the list is a non-breaking
2182
+ * change; respelling an existing code is not, so the code style is fixed here.
2183
+ */
2184
+ const LANGUAGE_CODES = [
2185
+ "en",
2186
+ "ja",
2187
+ "zh-CN",
2188
+ "zh-TW",
2189
+ "ko",
2190
+ "fr",
2191
+ "de",
2192
+ "es",
2193
+ "pt-BR",
2194
+ "ru"
2195
+ ];
2196
+ const LanguageSchema = zod_mini.z.enum(LANGUAGE_CODES);
2197
+ const LANGUAGE_DISPLAY = {
2198
+ en: {
2199
+ name: "English",
2200
+ claudecode: "english"
2201
+ },
2202
+ ja: {
2203
+ name: "Japanese",
2204
+ claudecode: "japanese"
2205
+ },
2206
+ "zh-CN": {
2207
+ name: "Simplified Chinese",
2208
+ claudecode: "simplified chinese"
2209
+ },
2210
+ "zh-TW": {
2211
+ name: "Traditional Chinese",
2212
+ claudecode: "traditional chinese"
2213
+ },
2214
+ ko: {
2215
+ name: "Korean",
2216
+ claudecode: "korean"
2217
+ },
2218
+ fr: {
2219
+ name: "French",
2220
+ claudecode: "french"
2221
+ },
2222
+ de: {
2223
+ name: "German",
2224
+ claudecode: "german"
2225
+ },
2226
+ es: {
2227
+ name: "Spanish",
2228
+ claudecode: "spanish"
2229
+ },
2230
+ "pt-BR": {
2231
+ name: "Brazilian Portuguese",
2232
+ claudecode: "brazilian portuguese"
2233
+ },
2234
+ ru: {
2235
+ name: "Russian",
2236
+ claudecode: "russian"
2237
+ }
2238
+ };
2239
+ /** English display name of a language, as used in the appended prompt. */
2240
+ function getLanguageName(language) {
2241
+ return LANGUAGE_DISPLAY[language].name;
2242
+ }
2243
+ /** The value Claude Code's `language` settings key expects for a language. */
2244
+ function getClaudecodeLanguageValue(language) {
2245
+ return LANGUAGE_DISPLAY[language].claudecode;
2246
+ }
2247
+ /**
2248
+ * The one sentence rulesync appends to generated root rule files when
2249
+ * `language` is set. Every supported language — `en` included — emits it, so
2250
+ * the instruction is explicit rather than implied by the absence of a block.
2251
+ */
2252
+ function buildLanguageInstruction(language) {
2253
+ return `You must always answer in ${getLanguageName(language)}. On the other hand, reasoning (thinking) should be in English to improve token efficiency.`;
2254
+ }
2255
+ /**
2256
+ * Separator that opens the appended block. A thematic break makes the
2257
+ * concatenation visible, so a reader can tell the sentence was appended by
2258
+ * rulesync rather than authored as part of the rules.
2259
+ */
2260
+ const LANGUAGE_BLOCK_SEPARATOR = "---";
2261
+ /**
2262
+ * Append the language block to a rule body: a blank line, the separator, a
2263
+ * blank line, then the instruction. A body that is empty (or whitespace only)
2264
+ * gets the instruction alone, because a file that starts with `---` reads as
2265
+ * the opening of a frontmatter block.
2266
+ */
2267
+ function appendLanguageBlock({ content, language }) {
2268
+ const instruction = buildLanguageInstruction(language);
2269
+ const body = content.trimEnd();
2270
+ if (body.length === 0) return instruction;
2271
+ return `${body}\n\n${LANGUAGE_BLOCK_SEPARATOR}\n\n${instruction}`;
2272
+ }
2273
+ const INSTRUCTIONS = LANGUAGE_CODES.map((code) => buildLanguageInstruction(code));
2274
+ const isBlank = (value) => value === " " || value === " ";
2275
+ /**
2276
+ * Remove one trailing block exactly as {@link appendLanguageBlock} writes it,
2277
+ * for any supported language: the separator on its own line, blank space
2278
+ * with at least one line break, the instruction, trailing whitespace. Only
2279
+ * the end of the body is inspected, so a sentence quoted mid-file is left
2280
+ * alone. Returns the body unchanged when no block closes it.
2281
+ *
2282
+ * Walks the body from its end with string operations rather than a regular
2283
+ * expression: an unanchored pattern over optional blank lines backtracks in
2284
+ * polynomial time, and a rule file padded with a few hundred thousand
2285
+ * trailing newlines would hang `rulesync import` on it.
2286
+ */
2287
+ function stripOneLanguageBlock(body) {
2288
+ const trimmed = body.trimEnd();
2289
+ const instruction = INSTRUCTIONS.find((candidate) => trimmed.endsWith(candidate));
2290
+ if (instruction === void 0) return body;
2291
+ const beforeInstruction = trimmed.slice(0, trimmed.length - instruction.length);
2292
+ const beforeGap = beforeInstruction.trimEnd();
2293
+ if (beforeGap.length === 0) return "";
2294
+ if (!beforeInstruction.slice(beforeGap.length).includes("\n")) return body;
2295
+ if (!beforeGap.endsWith(LANGUAGE_BLOCK_SEPARATOR)) return body;
2296
+ let lineStart = beforeGap.length - 3;
2297
+ while (lineStart > 0 && isBlank(beforeGap.charAt(lineStart - 1))) lineStart -= 1;
2298
+ if (lineStart > 0 && beforeGap.charAt(lineStart - 1) !== "\n") return body;
2299
+ return body.slice(0, lineStart).trimEnd();
2300
+ }
2301
+ /**
2302
+ * Remove the trailing language block(s) from an imported rule body so that
2303
+ * `rulesync import` followed by `rulesync generate` does not stack a second
2304
+ * copy. Repeats until nothing more comes off, so a file that already carries
2305
+ * two stacked blocks (generated twice by an older flow, say) comes back
2306
+ * clean. Returns the body unchanged when no block is present.
2307
+ */
2308
+ function stripLanguageBlock(body) {
2309
+ let current = body;
2310
+ for (;;) {
2311
+ const next = stripOneLanguageBlock(current);
2312
+ if (next === current) return current;
2313
+ current = next;
2314
+ }
2315
+ }
2316
+ //#endregion
2062
2317
  //#region src/utils/validation.ts
2063
2318
  /**
2064
2319
  * Shared validation utilities for input sanitization.
@@ -2130,7 +2385,15 @@ const ConfigParamsSchema = zod_mini.z.object({
2130
2385
  simulateCommands: (0, zod_mini.optional)(zod_mini.z.boolean()),
2131
2386
  simulateSubagents: (0, zod_mini.optional)(zod_mini.z.boolean()),
2132
2387
  simulateSkills: (0, zod_mini.optional)(zod_mini.z.boolean()),
2388
+ deriveSubprojectPathFromGlobs: (0, zod_mini.optional)(zod_mini.z.boolean()),
2133
2389
  flattenedCommandNaming: (0, zod_mini.optional)(FlattenedCommandNamingSchema),
2390
+ /**
2391
+ * Response language the generated rules steer the AI toward. Config-file
2392
+ * only (no CLI flag): it is a property of the project, not of one run.
2393
+ * Absent means "say nothing about language", which is why `en` is a real
2394
+ * value rather than the default.
2395
+ */
2396
+ language: (0, zod_mini.optional)(LanguageSchema),
2134
2397
  gitignoreTargetsOnly: (0, zod_mini.optional)(zod_mini.z.boolean()),
2135
2398
  gitignoreDestination: (0, zod_mini.optional)(GitignoreDestinationSchema),
2136
2399
  dryRun: (0, zod_mini.optional)(zod_mini.z.boolean()),
@@ -2270,7 +2533,9 @@ var Config = class Config {
2270
2533
  simulateCommands;
2271
2534
  simulateSubagents;
2272
2535
  simulateSkills;
2536
+ deriveSubprojectPathFromGlobs;
2273
2537
  flattenedCommandNaming;
2538
+ language;
2274
2539
  gitignoreTargetsOnly;
2275
2540
  gitignoreDestination;
2276
2541
  dryRun;
@@ -2294,7 +2559,7 @@ var Config = class Config {
2294
2559
  inputRoots;
2295
2560
  configFilePath;
2296
2561
  sources;
2297
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2562
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, deriveSubprojectPathFromGlobs, flattenedCommandNaming, language, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2298
2563
  assertTargetsFeaturesExclusive({
2299
2564
  targets,
2300
2565
  features
@@ -2326,7 +2591,9 @@ var Config = class Config {
2326
2591
  this.simulateCommands = simulateCommands ?? false;
2327
2592
  this.simulateSubagents = simulateSubagents ?? false;
2328
2593
  this.simulateSkills = simulateSkills ?? false;
2594
+ this.deriveSubprojectPathFromGlobs = deriveSubprojectPathFromGlobs ?? false;
2329
2595
  this.flattenedCommandNaming = flattenedCommandNaming ?? "basename";
2596
+ this.language = language;
2330
2597
  this.gitignoreTargetsOnly = gitignoreTargetsOnly ?? true;
2331
2598
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
2332
2599
  this.dryRun = dryRun ?? false;
@@ -2505,12 +2772,22 @@ var Config = class Config {
2505
2772
  getFlattenedCommandNaming() {
2506
2773
  return this.flattenedCommandNaming;
2507
2774
  }
2775
+ /**
2776
+ * The configured response language, or `undefined` when `rulesync.jsonc`
2777
+ * does not set one — in which case generation leaves language alone.
2778
+ */
2779
+ getLanguage() {
2780
+ return this.language;
2781
+ }
2508
2782
  getSimulateSubagents() {
2509
2783
  return this.simulateSubagents;
2510
2784
  }
2511
2785
  getSimulateSkills() {
2512
2786
  return this.simulateSkills;
2513
2787
  }
2788
+ getDeriveSubprojectPathFromGlobs() {
2789
+ return this.deriveSubprojectPathFromGlobs;
2790
+ }
2514
2791
  getGitignoreTargetsOnly() {
2515
2792
  return this.gitignoreTargetsOnly;
2516
2793
  }
@@ -2571,6 +2848,7 @@ const getDefaults = () => ({
2571
2848
  simulateCommands: false,
2572
2849
  simulateSubagents: false,
2573
2850
  simulateSkills: false,
2851
+ deriveSubprojectPathFromGlobs: false,
2574
2852
  flattenedCommandNaming: "basename",
2575
2853
  gitignoreTargetsOnly: true,
2576
2854
  gitignoreDestination: "gitignore",
@@ -2618,7 +2896,9 @@ const mergeConfigs = (baseConfig, localConfig) => {
2618
2896
  simulateCommands: localConfig.simulateCommands ?? baseConfig.simulateCommands,
2619
2897
  simulateSubagents: localConfig.simulateSubagents ?? baseConfig.simulateSubagents,
2620
2898
  simulateSkills: localConfig.simulateSkills ?? baseConfig.simulateSkills,
2899
+ deriveSubprojectPathFromGlobs: localConfig.deriveSubprojectPathFromGlobs ?? baseConfig.deriveSubprojectPathFromGlobs,
2621
2900
  flattenedCommandNaming: localConfig.flattenedCommandNaming ?? baseConfig.flattenedCommandNaming,
2901
+ language: localConfig.language ?? baseConfig.language,
2622
2902
  gitignoreTargetsOnly: localConfig.gitignoreTargetsOnly ?? baseConfig.gitignoreTargetsOnly,
2623
2903
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
2624
2904
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
@@ -2740,7 +3020,7 @@ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile,
2740
3020
  };
2741
3021
  }
2742
3022
  var ConfigResolver = class {
2743
- static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
3023
+ static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, deriveSubprojectPathFromGlobs, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
2744
3024
  const cwd = (0, node_path.resolve)(process.cwd());
2745
3025
  assertInputRootFieldsExclusive({
2746
3026
  inputRoot,
@@ -2839,6 +3119,11 @@ var ConfigResolver = class {
2839
3119
  file: configByFile.simulateSkills,
2840
3120
  fallback: getDefaults().simulateSkills
2841
3121
  }),
3122
+ deriveSubprojectPathFromGlobs: pick({
3123
+ cli: deriveSubprojectPathFromGlobs,
3124
+ file: configByFile.deriveSubprojectPathFromGlobs,
3125
+ fallback: getDefaults().deriveSubprojectPathFromGlobs
3126
+ }),
2842
3127
  gitignoreTargetsOnly: pick({
2843
3128
  cli: gitignoreTargetsOnly,
2844
3129
  file: configByFile.gitignoreTargetsOnly,
@@ -2863,6 +3148,7 @@ var ConfigResolver = class {
2863
3148
  configFilePath: validatedConfigPath,
2864
3149
  sources: configByFile.sources ?? getDefaults().sources,
2865
3150
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
3151
+ language: configByFile.language,
2866
3152
  configFileTargets: extractConfigFileTargets(configByFile.targets)
2867
3153
  });
2868
3154
  }
@@ -5043,7 +5329,7 @@ function collectPollutionKeyPaths({ node, path, found }) {
5043
5329
  * configs. Plain JSON is valid JSONC, so this is a drop-in replacement for
5044
5330
  * `JSON.parse` on files that may contain comments or trailing commas.
5045
5331
  */
5046
- function parseJsonc$8(content) {
5332
+ function parseJsonc$6(content) {
5047
5333
  return deepSanitize(parseStrict(content));
5048
5334
  }
5049
5335
  /**
@@ -7216,7 +7502,7 @@ function warnAboutDroppedKeys({ removed, sourcePath, logger }) {
7216
7502
  * are removed here instead, and reported.
7217
7503
  */
7218
7504
  function withoutBlankPermissionKeys({ fileContent, sourcePath, logger }) {
7219
- const parsed = parseJsonc$8(fileContent);
7505
+ const parsed = parseJsonc$6(fileContent);
7220
7506
  if (!isRecord$1(parsed)) return fileContent;
7221
7507
  const { config, removed } = stripBlankPermissionKeys(parsed);
7222
7508
  if (removed.patterns.size === 0 && removed.categories.size === 0) return fileContent;
@@ -7266,7 +7552,80 @@ const PERMISSION_OVERRIDE_KEY_ALIASES = {
7266
7552
  hermesagent: "hermes"
7267
7553
  };
7268
7554
  //#endregion
7555
+ //#region src/utils/glob-static-prefix.ts
7556
+ /**
7557
+ * Characters that make a path segment a pattern rather than a literal name.
7558
+ *
7559
+ * `(` `)` and `!` are included because extglob syntax (`+(a|b)`, `!(x)`) uses
7560
+ * them; treating a segment that carries one as static could name a directory
7561
+ * that no matched file actually lives under.
7562
+ */
7563
+ const GLOB_METACHARACTERS = /[*?[\]{}()!]/;
7564
+ /**
7565
+ * The directory every file matched by `glob` lives under, or `undefined` when
7566
+ * the pattern pins down no such directory.
7567
+ *
7568
+ * The result is the longest leading run of POSIX path segments that contain no
7569
+ * glob metacharacter, minus the final segment when the pattern ends in one that
7570
+ * is static: `packages/api/**\/*.ts` yields `packages/api`, while
7571
+ * `packages/api/README.md` names a file and yields `packages/api` as well, and
7572
+ * a bare `README.md` yields nothing. A leading `./` is stripped. The following
7573
+ * patterns are rejected outright because the directory they would name is
7574
+ * either not inside the output root or not a single directory at all:
7575
+ *
7576
+ * - negation patterns (`!packages/**`), which exclude rather than select;
7577
+ * - absolute paths (`/etc/**`, `C:/x/**`) and backslash-separated patterns;
7578
+ * - patterns with a `..` segment anywhere;
7579
+ * - patterns with a brace expansion or any other metacharacter in their first
7580
+ * segment (`{a,b}/**`), which have no static prefix.
7581
+ *
7582
+ * Brace expansion elsewhere ends the prefix without rejecting the pattern:
7583
+ * `packages/{api,web}/**` yields `packages`, the directory both alternatives
7584
+ * share, exactly as `packages/*\/**` would.
7585
+ */
7586
+ function getGlobStaticPrefix(glob) {
7587
+ if (glob.startsWith("!") || glob.includes("\\")) return;
7588
+ let pattern = glob;
7589
+ while (pattern.startsWith("./")) pattern = pattern.slice(2);
7590
+ if (pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) return;
7591
+ const segments = pattern.split("/");
7592
+ if (segments.includes("..")) return;
7593
+ const lastIndex = segments.length - 1;
7594
+ const prefix = [];
7595
+ for (const [index, segment] of segments.entries()) {
7596
+ if (segment === "" || GLOB_METACHARACTERS.test(segment)) break;
7597
+ if (segment === ".") continue;
7598
+ if (index === lastIndex) break;
7599
+ prefix.push(segment);
7600
+ }
7601
+ return prefix.length > 0 ? prefix.join("/") : void 0;
7602
+ }
7603
+ /**
7604
+ * The single directory every one of `globs` scopes, or `undefined` when the
7605
+ * list is empty, any pattern yields no prefix, or the patterns disagree.
7606
+ *
7607
+ * Several globs derive a directory only when each of them yields the same one:
7608
+ * picking the first pattern's prefix, or the common ancestor of all of them,
7609
+ * would place the rule somewhere the author never named.
7610
+ */
7611
+ function getGlobsStaticPrefix(globs) {
7612
+ let shared;
7613
+ for (const glob of globs) {
7614
+ const prefix = getGlobStaticPrefix(glob);
7615
+ if (prefix === void 0 || shared !== void 0 && prefix !== shared) return;
7616
+ shared = prefix;
7617
+ }
7618
+ return shared;
7619
+ }
7620
+ //#endregion
7269
7621
  //#region src/features/rules/rulesync-rule.ts
7622
+ /**
7623
+ * The `agentsmd.subprojectPath` value that asks for the path to be derived from
7624
+ * the rule's `globs` (see {@link resolveSubprojectPath}). It is a request, not a
7625
+ * path: the constructor replaces it with the derived directory, or drops it,
7626
+ * before any consumer reads the frontmatter.
7627
+ */
7628
+ const AUTO_SUBPROJECT_PATH = "auto";
7270
7629
  const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7271
7630
  root: zod_mini.z.optional(zod_mini.z.boolean()),
7272
7631
  localRoot: zod_mini.z.optional(zod_mini.z.boolean()),
@@ -7318,10 +7677,90 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7318
7677
  facet: zod_mini.z.optional(zod_mini.z.enum(["policies", "output-contracts"]))
7319
7678
  }))
7320
7679
  });
7680
+ /**
7681
+ * The `agentsmd.subprojectPath` every consumer should act on, resolved once so
7682
+ * that no target has to know how it came about:
7683
+ *
7684
+ * 1. an explicit directory in the frontmatter wins, and an explicit `""` is an
7685
+ * opt-out: the rule keeps its default placement and nothing is derived;
7686
+ * 2. otherwise, when the rule says `"auto"` or `deriveFromGlobs` is on, the
7687
+ * directory the rule's `globs` share (see `getGlobsStaticPrefix`);
7688
+ * 3. otherwise none, which keeps the rule in the target's modular directory.
7689
+ *
7690
+ * A root rule never nests, so it never derives. When a derivation yields
7691
+ * nothing the rule falls back to step 3, and only a rule that asked with
7692
+ * `"auto"` is told, once: it named a placement it did not get, so a warning
7693
+ * names the file to fix (an error would stop every other rule from
7694
+ * generating). The config option applies to every non-root rule, most of
7695
+ * which are general guidance whose globs, if any, were written as activation
7696
+ * hints (`["src/**\/*.ts", "test/**\/*.ts"]`) rather than as a directory;
7697
+ * warning about each of those on every generate would drown out real ones,
7698
+ * so config-driven derivation falls back silently.
7699
+ */
7700
+ function resolveSubprojectPath({ frontmatter, deriveFromGlobs, rulePath }) {
7701
+ const authored = frontmatter.agentsmd?.subprojectPath;
7702
+ if (authored === "") return;
7703
+ if (typeof authored === "string" && authored !== "auto") return authored;
7704
+ const requested = authored === AUTO_SUBPROJECT_PATH;
7705
+ if (!requested && !deriveFromGlobs) return;
7706
+ if (frontmatter.root) {
7707
+ if (requested) warnOnceWithFallback(void 0, `Ignoring agentsmd.subprojectPath: "${AUTO_SUBPROJECT_PATH}" on the root rule ${rulePath}: a root rule is never written as a nested AGENTS.md.`);
7708
+ return;
7709
+ }
7710
+ const globs = Array.isArray(frontmatter.globs) ? frontmatter.globs : [];
7711
+ const derived = getGlobsStaticPrefix(globs);
7712
+ if (derived === void 0 && requested) warnOnceWithFallback(void 0, `Could not derive agentsmd.subprojectPath for ${rulePath} from globs ${JSON.stringify(globs)}: every glob must start with the same wildcard-free directory (e.g. "packages/api/**/*"). The rule is generated without a nested AGENTS.md; set agentsmd.subprojectPath explicitly to nest it.`);
7713
+ return derived;
7714
+ }
7715
+ /**
7716
+ * `frontmatter` with `agentsmd.subprojectPath` replaced by its resolved value,
7717
+ * or removed when there is none, so the `"auto"` request never reaches a
7718
+ * consumer as if it were a directory name. An `agentsmd` block that held
7719
+ * nothing but the request goes with it, so a consumer sees the same shape it
7720
+ * would for a rule that never mentioned `agentsmd`. An authored `""` is left
7721
+ * as written: every consumer already reads it as "no nesting".
7722
+ */
7723
+ function withResolvedSubprojectPath({ frontmatter, deriveFromGlobs, rulePath }) {
7724
+ const authored = frontmatter.agentsmd?.subprojectPath;
7725
+ const resolved = resolveSubprojectPath({
7726
+ frontmatter,
7727
+ deriveFromGlobs,
7728
+ rulePath
7729
+ });
7730
+ if (resolved === authored || resolved === void 0 && authored !== "auto") return frontmatter;
7731
+ const { subprojectPath: _authored, ...agentsmd } = frontmatter.agentsmd ?? {};
7732
+ if (resolved !== void 0) return {
7733
+ ...frontmatter,
7734
+ agentsmd: {
7735
+ ...agentsmd,
7736
+ subprojectPath: resolved
7737
+ }
7738
+ };
7739
+ if (Object.keys(agentsmd).length === 0) {
7740
+ const { agentsmd: _empty, ...rest } = frontmatter;
7741
+ return rest;
7742
+ }
7743
+ return {
7744
+ ...frontmatter,
7745
+ agentsmd
7746
+ };
7747
+ }
7321
7748
  var RulesyncRule = class RulesyncRule extends RulesyncFile {
7749
+ /**
7750
+ * The frontmatter consumers read. It differs from what the file says in one
7751
+ * place: `agentsmd.subprojectPath` holds the resolved directory (see
7752
+ * `resolveSubprojectPath`), while `authoredFrontmatter` and
7753
+ * `getFileContent()` keep the authored value, so a rule written back out
7754
+ * still says `"auto"`.
7755
+ */
7322
7756
  frontmatter;
7757
+ /**
7758
+ * The frontmatter as written, after schema defaults but before
7759
+ * `agentsmd.subprojectPath` resolution: what `getFileContent()` serializes.
7760
+ */
7761
+ authoredFrontmatter;
7323
7762
  body;
7324
- constructor({ frontmatter, body, ...rest }) {
7763
+ constructor({ frontmatter, body, deriveSubprojectPathFromGlobs = false, ...rest }) {
7325
7764
  const parseResult = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
7326
7765
  if (!parseResult.success && rest.validate !== false) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(parseResult.error)}`);
7327
7766
  const parsedFrontmatter = parseResult.success ? parseResult.data : {
@@ -7332,7 +7771,12 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7332
7771
  ...rest,
7333
7772
  fileContent: stringifyFrontmatter(body, parsedFrontmatter)
7334
7773
  });
7335
- this.frontmatter = parsedFrontmatter;
7774
+ this.authoredFrontmatter = parsedFrontmatter;
7775
+ this.frontmatter = withResolvedSubprojectPath({
7776
+ frontmatter: parsedFrontmatter,
7777
+ deriveFromGlobs: deriveSubprojectPathFromGlobs,
7778
+ rulePath: (0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)
7779
+ });
7336
7780
  this.body = body;
7337
7781
  }
7338
7782
  static getSettablePaths() {
@@ -7341,9 +7785,23 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7341
7785
  legacy: { relativeDirPath: RULESYNC_RELATIVE_DIR_PATH }
7342
7786
  };
7343
7787
  }
7788
+ /**
7789
+ * The frontmatter to act on: `agentsmd.subprojectPath` is the resolved
7790
+ * placement, never `"auto"`.
7791
+ */
7344
7792
  getFrontmatter() {
7345
7793
  return this.frontmatter;
7346
7794
  }
7795
+ /**
7796
+ * The frontmatter as the file states it, `agentsmd.subprojectPath: "auto"`
7797
+ * included. This is the view to hand back to whoever edits the file (the
7798
+ * MCP rule tools): returning the resolved placement instead would make a
7799
+ * get → edit → put round trip hardcode the derived directory, or drop the
7800
+ * request when nothing could be derived.
7801
+ */
7802
+ getAuthoredFrontmatter() {
7803
+ return this.authoredFrontmatter;
7804
+ }
7347
7805
  validate() {
7348
7806
  if (!this.frontmatter) return {
7349
7807
  success: true,
@@ -7359,7 +7817,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7359
7817
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
7360
7818
  };
7361
7819
  }
7362
- static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
7820
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, deriveSubprojectPathFromGlobs = false }) {
7363
7821
  const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
7364
7822
  const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
7365
7823
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
@@ -7378,7 +7836,8 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7378
7836
  relativeFilePath,
7379
7837
  frontmatter: validatedFrontmatter,
7380
7838
  body: content.trim(),
7381
- validate
7839
+ validate,
7840
+ deriveSubprojectPathFromGlobs
7382
7841
  });
7383
7842
  }
7384
7843
  getBody() {
@@ -7406,6 +7865,21 @@ function containsPathSeparator(name) {
7406
7865
  return name.includes("/") || name.includes("\\");
7407
7866
  }
7408
7867
  /**
7868
+ * The permission bits worth carrying from a source file: only an executable
7869
+ * one has a mode the copy must keep, and only its read and execute bits plus
7870
+ * the owner's write bit are kept. Group and world write bits are dropped the
7871
+ * way a umask would drop them, so a source sitting on a mount that reports
7872
+ * everything as 0777 does not turn the generated copy world-writable; setuid,
7873
+ * setgid and sticky are never carried. Windows reports no executable bit, so
7874
+ * nothing is carried there and the copy takes the platform default, as it
7875
+ * always has.
7876
+ */
7877
+ function carriedFileMode(mode) {
7878
+ const permissionBits = mode & 493;
7879
+ if ((permissionBits & 73) === 0) return {};
7880
+ return { fileMode: permissionBits };
7881
+ }
7882
+ /**
7409
7883
  * Directories that hold credentials. Excluding these protects something, so
7410
7884
  * their exclusion is reported rather than silent.
7411
7885
  */
@@ -8119,6 +8593,7 @@ var AiDir = class AiDir {
8119
8593
  * files says so rather than generating a directory that is quietly short.
8120
8594
  */
8121
8595
  static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
8596
+ if (truncations.size > 0 || unreadablePaths.length > 0) recordIncompleteCarriedFiles();
8122
8597
  if (truncations.has("depth")) warnOnceWithFallback(void 0, `Not carrying the entries more than 12 directories below ${reportedDirPath}: a skill directory is walked to that depth only. A symbolic link that reaches a large tree is the usual cause.`);
8123
8598
  if (truncations.has("count")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} beyond the first ${MAX_CARRIED_FILES}: a directory may carry at most that many files. A symbolic link that reaches a large tree is the usual cause.`);
8124
8599
  if (truncations.has("directories")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} below the first ${MAX_CARRIED_DIRECTORIES} directories: a directory may carry files from at most that many directories. A symbolic link that reaches a large tree is the usual cause.`);
@@ -8217,12 +8692,15 @@ var AiDir = class AiDir {
8217
8692
  try {
8218
8693
  fileHandle = await (0, node_fs_promises.open)(classifiedPath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
8219
8694
  } catch (error) {
8695
+ recordIncompleteCarriedFiles();
8220
8696
  warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
8221
8697
  continue;
8222
8698
  }
8223
8699
  try {
8224
- const fileSize = (await fileHandle.stat()).size;
8700
+ const fileStats = await fileHandle.stat();
8701
+ const fileSize = fileStats.size;
8225
8702
  if (carriedBytes + fileSize > 104857600) {
8703
+ recordIncompleteCarriedFiles();
8226
8704
  warnOnceWithFallback(void 0, `Not carrying ${filteredPaths.length - index} of the ${filteredPaths.length} entries under ${reportedDirPath}: a directory may carry at most ${MAX_CARRIED_BYTES / 1024 / 1024}MB. A symbolic link that reaches a large tree is the usual cause.`);
8227
8705
  break;
8228
8706
  }
@@ -8230,9 +8708,11 @@ var AiDir = class AiDir {
8230
8708
  carriedBytes += fileBuffer.byteLength;
8231
8709
  files.push({
8232
8710
  relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
8233
- fileBuffer
8711
+ fileBuffer,
8712
+ ...carriedFileMode(fileStats.mode)
8234
8713
  });
8235
8714
  } catch (error) {
8715
+ recordIncompleteCarriedFiles();
8236
8716
  warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
8237
8717
  } finally {
8238
8718
  await fileHandle.close();
@@ -8265,6 +8745,9 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8265
8745
  targets: zod_mini.z._default(RulesyncTargetsSchema, ["*"]),
8266
8746
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
8267
8747
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
8748
+ license: zod_mini.z.optional(zod_mini.z.string()),
8749
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8750
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
8268
8751
  claudecode: zod_mini.z.optional(zod_mini.z.looseObject({
8269
8752
  when_to_use: zod_mini.z.optional(zod_mini.z.string()),
8270
8753
  "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
@@ -8314,7 +8797,7 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8314
8797
  kilo: zod_mini.z.optional(zod_mini.z.looseObject({
8315
8798
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8316
8799
  license: zod_mini.z.optional(zod_mini.z.string()),
8317
- compatibility: zod_mini.z.optional(zod_mini.z.looseObject({})),
8800
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8318
8801
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8319
8802
  })),
8320
8803
  kiro: zod_mini.z.optional(zod_mini.z.looseObject({
@@ -8691,6 +9174,61 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
8691
9174
  function resolveUserInvocable({ rootFrontmatter, section }) {
8692
9175
  return section?.["user-invocable"] ?? rootFrontmatter["user-invocable"];
8693
9176
  }
9177
+ /**
9178
+ * Resolve the effective `license` value for a tool skill.
9179
+ *
9180
+ * The rulesync skill frontmatter exposes a root-level `license` default that
9181
+ * applies to every tool modelling the Agent Skills standard field; the list of
9182
+ * those tools lives in `docs/reference/file-formats.md`. Each tool's own
9183
+ * section may override that default with a per-target value. A defined section
9184
+ * value always wins over the root default.
9185
+ *
9186
+ * The section value type is generic because `factorydroid` deliberately types
9187
+ * the packaging fields as `unknown` (Droid never validates them).
9188
+ *
9189
+ * @returns The resolved value, or `undefined` when neither value is set.
9190
+ */
9191
+ function resolveLicense({ rootFrontmatter, section }) {
9192
+ return section?.license ?? rootFrontmatter.license;
9193
+ }
9194
+ /**
9195
+ * Resolve the effective `compatibility` value for a tool skill.
9196
+ *
9197
+ * The rulesync skill frontmatter exposes a root-level `compatibility` default
9198
+ * that applies to every tool modelling the Agent Skills standard field; the
9199
+ * list of those tools lives in `docs/reference/file-formats.md`. Each tool's
9200
+ * own section may override that default with a per-target value. A defined
9201
+ * section value always wins over the root default.
9202
+ *
9203
+ * The root value keeps the rulesync shape (a string, or the legacy object
9204
+ * form); any per-target normalization — such as the Agent Skills spec's string
9205
+ * coercion — stays with the adapter.
9206
+ * The section value type is generic because `factorydroid` deliberately types
9207
+ * the packaging fields as `unknown` (Droid never validates them).
9208
+ *
9209
+ * @returns The resolved value, or `undefined` when neither value is set.
9210
+ */
9211
+ function resolveCompatibility({ rootFrontmatter, section }) {
9212
+ return section?.compatibility ?? rootFrontmatter.compatibility;
9213
+ }
9214
+ /**
9215
+ * Resolve the effective `metadata` value for a tool skill.
9216
+ *
9217
+ * The rulesync skill frontmatter exposes a root-level `metadata` default that
9218
+ * applies to every tool modelling the Agent Skills standard field; the list of
9219
+ * those tools lives in `docs/reference/file-formats.md`. Each tool's own
9220
+ * section may override that default with a per-target value. A defined section
9221
+ * value always wins over the root default; the two maps are never merged key
9222
+ * by key.
9223
+ *
9224
+ * The section value type is generic because `factorydroid` deliberately types
9225
+ * the packaging fields as `unknown` (Droid never validates them).
9226
+ *
9227
+ * @returns The resolved value, or `undefined` when neither value is set.
9228
+ */
9229
+ function resolveMetadata({ rootFrontmatter, section }) {
9230
+ return section?.metadata ?? rootFrontmatter.metadata;
9231
+ }
8694
9232
  //#endregion
8695
9233
  //#region src/constants/augmentcode-paths.ts
8696
9234
  const AUGMENTCODE_DIR = ".augment";
@@ -10635,6 +11173,40 @@ var RovodevCheck = class extends AggregatedToolCheck {
10635
11173
  }
10636
11174
  };
10637
11175
  //#endregion
11176
+ //#region src/constants/claudecode-paths.ts
11177
+ /**
11178
+ * Claude Code configuration-layout conventions.
11179
+ *
11180
+ * Single source of truth for where Claude Code expects its files
11181
+ * (directories, file names, scope-specific paths). Every feature module
11182
+ * (rules, commands, skills, subagents, ignore, mcp, permissions, hooks)
11183
+ * and the gitignore entry registry import from here, so a change in the
11184
+ * Claude Code conventions is a change to this file only.
11185
+ */
11186
+ /** Root directory for Claude Code configuration, relative to the scope root. */
11187
+ const CLAUDECODE_DIR = ".claude";
11188
+ const CLAUDECODE_RULE_FILE_NAME = "CLAUDE.md";
11189
+ const CLAUDECODE_LOCAL_RULE_FILE_NAME = "CLAUDE.local.md";
11190
+ /** Modular rules directory name under `.claude/` (current format). */
11191
+ const CLAUDECODE_RULES_DIR_NAME = "rules";
11192
+ /** Memories directory name under `.claude/` (legacy format). */
11193
+ const CLAUDECODE_MEMORIES_DIR_NAME = "memories";
11194
+ const CLAUDECODE_COMMANDS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "commands");
11195
+ const CLAUDECODE_AGENTS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "agents");
11196
+ const CLAUDECODE_SKILLS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "skills");
11197
+ const CLAUDECODE_SCHEDULED_TASKS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "scheduled-tasks");
11198
+ const CLAUDECODE_SETTINGS_FILE_NAME = "settings.json";
11199
+ const CLAUDECODE_SETTINGS_LOCAL_FILE_NAME = "settings.local.json";
11200
+ /**
11201
+ * The published JSON Schema for both settings files (SchemaStore). Written as
11202
+ * the `$schema` key of every settings file rulesync generates, when the file
11203
+ * does not already state one, so editors offer completion, hover
11204
+ * documentation and validation for the keys around the ones rulesync writes.
11205
+ */
11206
+ const CLAUDECODE_SETTINGS_SCHEMA_URL = "https://json.schemastore.org/claude-code-settings.json";
11207
+ const CLAUDECODE_MCP_FILE_NAME = ".mcp.json";
11208
+ const CLAUDECODE_GLOBAL_MCP_FILE_NAME = ".claude.json";
11209
+ //#endregion
10638
11210
  //#region src/constants/codexcli-paths.ts
10639
11211
  const CODEXCLI_DIR = ".codex";
10640
11212
  const CODEXCLI_PROMPTS_DIR_PATH = (0, node_path.join)(CODEXCLI_DIR, "prompts");
@@ -10655,6 +11227,52 @@ const CODEXCLI_OVERRIDE_KEYS = [
10655
11227
  "tui"
10656
11228
  ];
10657
11229
  //#endregion
11230
+ //#region src/utils/quote-value.ts
11231
+ /**
11232
+ * How much of a value read off disk a diagnostic quotes.
11233
+ *
11234
+ * Enough to recognize which entry is meant, and no more. A warning names the
11235
+ * offending value so the reader can find it, but the values these warnings
11236
+ * quote come from files rulesync did not write — a tool's own settings, a
11237
+ * machine-local overrides file, a repository fetched from elsewhere — and they
11238
+ * no longer stop at a terminal: they travel into a `--json` document another
11239
+ * program parses and into an MCP result an agent reads as context. A command
11240
+ * line or a header is the shape most likely to carry a credential, and a long
11241
+ * value is the shape most likely to carry instructions aimed at the agent.
11242
+ */
11243
+ const MAX_QUOTED_VALUE_LENGTH = 60;
11244
+ /**
11245
+ * A short, quotable rendering of a value for a diagnostic.
11246
+ *
11247
+ * Serialized rather than interpolated, because an unquoted value is what lets a
11248
+ * crafted one read as a second line; stripped of the control characters
11249
+ * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
11250
+ * bidirectional overrides); and truncated.
11251
+ */
11252
+ function quoteValueForWarning(value) {
11253
+ return truncateText({
11254
+ text: stripControlCharacters(serialize(value)),
11255
+ maxLength: MAX_QUOTED_VALUE_LENGTH,
11256
+ suffix: "…(truncated)"
11257
+ });
11258
+ }
11259
+ function serialize(value) {
11260
+ try {
11261
+ return JSON.stringify(value, stripStrings) ?? String(value);
11262
+ } catch {
11263
+ return `[unserializable ${typeof value}]`;
11264
+ }
11265
+ }
11266
+ /**
11267
+ * Strip the control characters out of every string before `JSON.stringify`
11268
+ * sees it, not only out of the document it produces: `JSON.stringify` escapes
11269
+ * a C0 character into the six literal characters `\u001b`, which no later pass
11270
+ * over the output can recognize as a control character again.
11271
+ */
11272
+ function stripStrings(_key, value) {
11273
+ return typeof value === "string" ? stripControlCharacters(value) : value;
11274
+ }
11275
+ //#endregion
10658
11276
  //#region src/features/shared/shared-config-gateway.ts
10659
11277
  /**
10660
11278
  * Rebuild a parsed document without its prototype-pollution keys.
@@ -10790,39 +11408,35 @@ function detectJsoncFormattingOptions({ text, root }) {
10790
11408
  eol
10791
11409
  };
10792
11410
  }
10793
- /**
10794
- * Whether the document states a key no edit-based write can be trusted with.
10795
- *
10796
- * Two kinds, both answered from the syntax tree because the parsed value no
10797
- * longer knows about either:
10798
- *
10799
- * - A key stated twice. That is legal JSON text which every reader resolves
10800
- * last-wins, while `modify` edits the *first* occurrence — so an edit-based
10801
- * write would land on the dead copy and leave the live one saying whatever
10802
- * it said before. For an owned key that is a silent ownership failure: a
10803
- * `deny` rulesync just wrote would sit above the `allow` the tool reads.
10804
- * - `__proto__`, `constructor` or `prototype`. None survives into the parsed
10805
- * document — a nested one is dropped, a root-level `__proto__` replaces the
10806
- * root's prototype — so an edit-based write would find no difference to
10807
- * apply and leave the key in the file.
10808
- *
10809
- * The whole-document writer resolves duplicates last-wins and drops pollution
10810
- * keys, which is what it has always done, so those files go to it.
10811
- */
10812
- function statesUneditableKeys(node) {
10813
- if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
10814
- if (node.type !== "object") return false;
11411
+ function uneditableKeyOf(node) {
11412
+ if (node.type === "array") {
11413
+ for (const child of node.children ?? []) {
11414
+ const found = uneditableKeyOf(child);
11415
+ if (found !== void 0) return found;
11416
+ }
11417
+ return;
11418
+ }
11419
+ if (node.type !== "object") return void 0;
10815
11420
  const seen = /* @__PURE__ */ new Set();
10816
11421
  for (const property of node.children ?? []) {
10817
11422
  const key = property.children?.[0]?.value;
10818
11423
  if (typeof key === "string") {
10819
- if (seen.has(key) || isPrototypePollutionKey(key)) return true;
11424
+ if (isPrototypePollutionKey(key)) return {
11425
+ kind: "prototype-pollution",
11426
+ key
11427
+ };
11428
+ if (seen.has(key)) return {
11429
+ kind: "duplicate",
11430
+ key
11431
+ };
10820
11432
  seen.add(key);
10821
11433
  }
10822
11434
  const value = property.children?.[1];
10823
- if (value !== void 0 && statesUneditableKeys(value)) return true;
11435
+ if (value !== void 0) {
11436
+ const found = uneditableKeyOf(value);
11437
+ if (found !== void 0) return found;
11438
+ }
10824
11439
  }
10825
- return false;
10826
11440
  }
10827
11441
  /**
10828
11442
  * The offset just past the whitespace and comments starting at `from`.
@@ -11003,7 +11617,8 @@ function notesAt({ text, from }) {
11003
11617
  /**
11004
11618
  * Detach the notes written at the point where the object at `path` will take a
11005
11619
  * new key: after its last property (and around the comma a trailing-comma file
11006
- * spells there), or just inside the `{` when it has no properties yet.
11620
+ * spells there), or just inside the `{` when it has no properties yet or when
11621
+ * the key is to be written first (`at: "start"`).
11007
11622
  *
11008
11623
  * `modify` computes its insert from exactly that point — in front of a note
11009
11624
  * written there — so applying the edit unchanged re-emits the note *after* the
@@ -11016,11 +11631,11 @@ function notesAt({ text, from }) {
11016
11631
  *
11017
11632
  * Returns `undefined` when there is no such note, which is the common case.
11018
11633
  */
11019
- function detachTrailingNote({ text, path }) {
11634
+ function detachInsertionNote({ text, path, at }) {
11020
11635
  const root = (0, jsonc_parser.parseTree)(text, [], { allowTrailingComma: true });
11021
11636
  const object = root === void 0 ? void 0 : (0, jsonc_parser.findNodeAtLocation)(root, [...path]);
11022
11637
  if (object?.type !== "object") return void 0;
11023
- const property = object.children?.at(-1);
11638
+ const property = at === "end" ? object.children?.at(-1) : void 0;
11024
11639
  const anchorKey = property?.children?.[0]?.value;
11025
11640
  if (property !== void 0 && typeof anchorKey !== "string") return void 0;
11026
11641
  const spans = notesAt({
@@ -11037,7 +11652,7 @@ function detachTrailingNote({ text, path }) {
11037
11652
  };
11038
11653
  }
11039
11654
  /**
11040
- * Put a note detached by {@link detachTrailingNote} back where it was: after
11655
+ * Put a note detached by {@link detachInsertionNote} back where it was: after
11041
11656
  * the property it describes (behind the comma the insert gave that property),
11042
11657
  * or just inside the `{` of the object it was written in when there was no
11043
11658
  * property to describe. Returns `undefined` if that place can no longer be
@@ -11060,16 +11675,23 @@ function reattachTrailingNote({ text, path, anchorKey, note }) {
11060
11675
  return text.slice(0, cursor) + note + text.slice(cursor);
11061
11676
  }
11062
11677
  /**
11063
- * Write `value` at `[...path, key]`, keeping the trailing note of the property
11064
- * the new key is inserted after (see {@link detachTrailingNote}). Replacing an
11065
- * existing key needs none of this: `modify` rewrites the value's own span and
11066
- * leaves every comment where it is.
11067
- */
11068
- function insertJsoncProperty({ text, path, key, value, options }) {
11069
- const write = (source) => (0, jsonc_parser.applyEdits)(source, (0, jsonc_parser.modify)(source, [...path, key], value, options));
11070
- const detached = detachTrailingNote({
11678
+ * Write `value` at `[...path, key]`, keeping the note written at the point of
11679
+ * insertion where its author put it (see {@link detachInsertionNote}): the
11680
+ * trailing note of the property the new key is appended after, or — for a
11681
+ * `leading` key, which `modify` places before every other property the note
11682
+ * written just inside the `{`. Replacing an existing key needs none of this:
11683
+ * `modify` rewrites the value's own span and leaves every comment where it is.
11684
+ */
11685
+ function insertJsoncProperty({ text, path, key, value, options, leading = false }) {
11686
+ const modification = leading ? {
11687
+ ...options,
11688
+ getInsertionIndex: () => 0
11689
+ } : options;
11690
+ const write = (source) => (0, jsonc_parser.applyEdits)(source, (0, jsonc_parser.modify)(source, [...path, key], value, modification));
11691
+ const detached = detachInsertionNote({
11071
11692
  text,
11072
- path
11693
+ path,
11694
+ at: leading ? "start" : "end"
11073
11695
  });
11074
11696
  if (detached === void 0) return write(text);
11075
11697
  return reattachTrailingNote({
@@ -11189,7 +11811,7 @@ function countJsoncEdits({ base, next, depth }) {
11189
11811
  * large enough and enough of it changing, the product of the two is what
11190
11812
  * {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
11191
11813
  */
11192
- function applyJsoncObjectEdits({ text, base, next, path, options }) {
11814
+ function applyJsoncObjectEdits({ text, base, next, path, options, leadingKeys }) {
11193
11815
  let result = text;
11194
11816
  for (const [key, value] of Object.entries(next)) {
11195
11817
  if (isPrototypePollutionKey(key)) continue;
@@ -11208,7 +11830,8 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11208
11830
  base: previous,
11209
11831
  next: value,
11210
11832
  path: [...path, key],
11211
- options
11833
+ options,
11834
+ leadingKeys: []
11212
11835
  });
11213
11836
  continue;
11214
11837
  }
@@ -11222,7 +11845,8 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11222
11845
  path,
11223
11846
  key,
11224
11847
  value,
11225
- options
11848
+ options,
11849
+ leading: leadingKeys.includes(key)
11226
11850
  });
11227
11851
  }
11228
11852
  for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
@@ -11252,7 +11876,7 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11252
11876
  * here have already decided (via `invalidRootPolicy`) that such a file is
11253
11877
  * replaced;
11254
11878
  * - a file stating the same key twice, or using `__proto__`, `constructor` or
11255
- * `prototype` as a key (see {@link statesUneditableKeys});
11879
+ * `prototype` as a key (see {@link uneditableKeyOf});
11256
11880
  * - a file so large, with so much of it changing, that editing it key by key
11257
11881
  * would take longer than a user would wait (see
11258
11882
  * {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
@@ -11260,26 +11884,54 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11260
11884
  * {@link JSONC_EDIT_WRITTEN_BYTES});
11261
11885
  * - a file the editor itself refuses, which it answers with an exception
11262
11886
  * rather than a result.
11887
+ *
11888
+ * Every one of those fallbacks except the empty file is reported once per run
11889
+ * through `logger` (or the fallback logger when none is given): the author's
11890
+ * comments are what the edit path exists to keep, and a `generate` that drops
11891
+ * them should say so rather than let the loss show up in the next diff.
11892
+ * `filePath` names the file in that warning; callers that know it should pass
11893
+ * it, since the once-per-run token is the message itself and two nameless
11894
+ * files would share one.
11895
+ *
11896
+ * `leadingKeys` names the root keys that go in front of every other property
11897
+ * when the edit path inserts them (the whole-document writer keeps the order
11898
+ * `document` states, so the caller places them there itself). A key the file
11899
+ * already has stays where the file put it.
11263
11900
  */
11264
- function serializeSharedConfig({ format, document, existingContent }) {
11901
+ function serializeSharedConfig({ format, document, existingContent, leadingKeys = [], filePath, logger }) {
11265
11902
  const whole = stringifySharedConfig({
11266
11903
  format,
11267
11904
  document
11268
11905
  });
11269
11906
  if (format !== "jsonc" || existingContent.trim() === "") return whole;
11907
+ const wholeBecause = (reason) => {
11908
+ warnAboutWholeJsoncRewrite({
11909
+ filePath,
11910
+ reason,
11911
+ logger
11912
+ });
11913
+ return whole;
11914
+ };
11270
11915
  try {
11271
11916
  const errors = [];
11272
11917
  const root = (0, jsonc_parser.parseTree)(existingContent, errors, { allowTrailingComma: true });
11273
- if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
11918
+ if (root === void 0 || errors.length > 0) return wholeBecause("it could not be parsed");
11919
+ if (root.type !== "object") return wholeBecause("its root is not an object");
11920
+ const uneditable = uneditableKeyOf(root);
11921
+ if (uneditable !== void 0) {
11922
+ const key = quoteValueForWarning(uneditable.key);
11923
+ return wholeBecause(uneditable.kind === "duplicate" ? `it states the key ${key} twice` : `it uses the key ${key}, which rulesync drops from every document it parses`);
11924
+ }
11274
11925
  const base = sanitizeSharedConfigValue((0, jsonc_parser.getNodeValue)(root));
11275
- if (!isPlainObject$1(base)) return whole;
11926
+ if (!isPlainObject$1(base)) return wholeBecause("its root is not an object");
11276
11927
  const span = Math.max(existingContent.length, whole.length);
11277
11928
  const cost = countJsoncEdits({
11278
11929
  base,
11279
11930
  next: document,
11280
11931
  depth: 0
11281
11932
  });
11282
- if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
11933
+ if (cost.written > JSONC_EDIT_WRITTEN_BYTES) return wholeBecause("its changed keys write more new text than editing in place can afford");
11934
+ if (cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return wholeBecause("it is too large, with too much of it changing, to edit key by key");
11283
11935
  return applyJsoncObjectEdits({
11284
11936
  text: existingContent,
11285
11937
  base,
@@ -11288,13 +11940,25 @@ function serializeSharedConfig({ format, document, existingContent }) {
11288
11940
  options: { formattingOptions: detectJsoncFormattingOptions({
11289
11941
  text: existingContent,
11290
11942
  root
11291
- }) }
11943
+ }) },
11944
+ leadingKeys
11292
11945
  });
11293
- } catch {
11294
- return whole;
11946
+ } catch (error) {
11947
+ return wholeBecause(`the editor refused it (${stripControlCharacters(formatError(error))})`);
11295
11948
  }
11296
11949
  }
11297
11950
  /**
11951
+ * Report, once per run and file, that a JSONC file is being written whole
11952
+ * instead of edited in place. Named by `filePath` when the caller knows it;
11953
+ * a caller serializing text it never read from disk has no name to give.
11954
+ */
11955
+ function warnAboutWholeJsoncRewrite({ filePath, reason, logger }) {
11956
+ const subject = filePath === void 0 ? "A shared JSONC config file" : quoteForLog(filePath);
11957
+ try {
11958
+ warnOnceWithFallback(logger, `${subject} is rewritten whole rather than edited in place because ${reason}; comments, blank lines and key order in the existing file are not preserved.`);
11959
+ } catch {}
11960
+ }
11961
+ /**
11298
11962
  * Shallow merge: every top-level key in `patch` replaces the base key
11299
11963
  * wholesale; all other base keys are preserved. The policy for a feature that
11300
11964
  * owns a fixed set of top-level keys.
@@ -11332,6 +11996,7 @@ function mergeSharedConfigDeep({ base, patch }) {
11332
11996
  return result;
11333
11997
  }
11334
11998
  const CLAUDE_SETTINGS_SHARED_FILE_KEY = ".claude/settings.json";
11999
+ const CLAUDE_SETTINGS_LOCAL_SHARED_FILE_KEY = ".claude/settings.local.json";
11335
12000
  const HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml";
11336
12001
  const HERMES_WIN32_CONFIG_SHARED_FILE_KEY = "AppData/Local/hermes/config.yaml";
11337
12002
  const HERMES_HOME_CONFIG_SHARED_FILE_KEY = "config.yaml";
@@ -11441,9 +12106,28 @@ const ZCODE_CONFIG_DECLARATION = {
11441
12106
  ownedKeys: ["mcp"]
11442
12107
  } }
11443
12108
  };
12109
+ /**
12110
+ * What the two Claude Code settings files have in common: both are plain JSON,
12111
+ * and both validate against the one published schema, which the gateway
12112
+ * points every file it writes at. `.claude/settings.local.json` is the same
12113
+ * file with fewer writers — the ignore feature's `fileMode: "local"` and the
12114
+ * project-scope `language` setting go there — so the shape is declared once
12115
+ * and the feature sets separately.
12116
+ *
12117
+ * `language` is the rules feature's: the root `language` key of
12118
+ * `rulesync.jsonc` is written natively for Claude Code instead of as a prompt
12119
+ * block in CLAUDE.md. At project scope it lands in the local file (a
12120
+ * per-developer preference, not a team commitment); at global scope it lands
12121
+ * in `~/.claude/settings.json`, because Claude Code reads no
12122
+ * `~/.claude/settings.local.json`.
12123
+ */
12124
+ const CLAUDE_SETTINGS_FILE_SHAPE = {
12125
+ format: "json",
12126
+ ensuredKeys: { $schema: CLAUDECODE_SETTINGS_SCHEMA_URL }
12127
+ };
11444
12128
  const SHARED_CONFIG_OWNERSHIP = {
11445
12129
  [CLAUDE_SETTINGS_SHARED_FILE_KEY]: {
11446
- format: "json",
12130
+ ...CLAUDE_SETTINGS_FILE_SHAPE,
11447
12131
  features: {
11448
12132
  ignore: {
11449
12133
  kind: "custom",
@@ -11456,6 +12140,23 @@ const SHARED_CONFIG_OWNERSHIP = {
11456
12140
  permissions: {
11457
12141
  kind: "custom",
11458
12142
  policyFunction: "applyPermissions"
12143
+ },
12144
+ rules: {
12145
+ kind: "replace-owned-keys",
12146
+ ownedKeys: ["language"]
12147
+ }
12148
+ }
12149
+ },
12150
+ [CLAUDE_SETTINGS_LOCAL_SHARED_FILE_KEY]: {
12151
+ ...CLAUDE_SETTINGS_FILE_SHAPE,
12152
+ features: {
12153
+ ignore: {
12154
+ kind: "custom",
12155
+ policyFunction: "applyIgnoreReadDenies"
12156
+ },
12157
+ rules: {
12158
+ kind: "replace-owned-keys",
12159
+ ownedKeys: ["language"]
11459
12160
  }
11460
12161
  }
11461
12162
  },
@@ -11897,17 +12598,69 @@ const SHARED_CONFIG_OWNERSHIP = {
11897
12598
  }
11898
12599
  };
11899
12600
  /**
12601
+ * The keys a declaration ensures, added to `document` when it lacks them and
12602
+ * placed in front of every key it has. A document that states all of them —
12603
+ * with whatever value — comes back as it is, and no key of the document
12604
+ * moves: the whole-document writer emits keys in this order, and the JSONC
12605
+ * edit path is told which keys lead (see {@link serializeSharedConfig}).
12606
+ */
12607
+ function withEnsuredKeys({ document, ensuredKeys }) {
12608
+ const missing = Object.entries(ensuredKeys).filter(([key]) => !isPrototypePollutionKey(key) && !Object.hasOwn(document, key));
12609
+ if (missing.length === 0) return document;
12610
+ return {
12611
+ ...Object.fromEntries(missing),
12612
+ ...document
12613
+ };
12614
+ }
12615
+ function serializeDeclaredSharedConfig({ declaration, document, existingContent, filePath, logger }) {
12616
+ const ensuredKeys = declaration.ensuredKeys ?? {};
12617
+ return serializeSharedConfig({
12618
+ format: declaration.format,
12619
+ document: withEnsuredKeys({
12620
+ document,
12621
+ ensuredKeys
12622
+ }),
12623
+ existingContent,
12624
+ leadingKeys: Object.keys(ensuredKeys),
12625
+ filePath,
12626
+ logger
12627
+ });
12628
+ }
12629
+ /**
12630
+ * Serialize a merged document back over a gateway-managed shared file, under
12631
+ * that file's declaration: its format, and the keys the gateway ensures on it
12632
+ * (see {@link SharedConfigFileDeclaration.ensuredKeys}). This is the one
12633
+ * write path of the gateway — {@link applySharedConfigPatch} ends in it, and a
12634
+ * feature whose policy is `custom` serializes its own merge through it — so a
12635
+ * key the gateway ensures is emitted whichever feature writes the file.
12636
+ * Throws when the file is undeclared. A JSONC file that had to be written
12637
+ * whole is warned about through `logger`, named by `filePath` when given and
12638
+ * by `fileKey` otherwise (see {@link serializeSharedConfig}).
12639
+ */
12640
+ function serializeSharedConfigFile({ fileKey, document, existingContent, filePath, logger }) {
12641
+ const declaration = SHARED_CONFIG_OWNERSHIP[fileKey];
12642
+ if (!declaration) throw new Error(`Shared config file '${fileKey}' has no SHARED_CONFIG_OWNERSHIP declaration; declare its writers and policies before writing it through the gateway.`);
12643
+ return serializeDeclaredSharedConfig({
12644
+ declaration,
12645
+ document,
12646
+ existingContent,
12647
+ filePath: filePath ?? fileKey,
12648
+ logger
12649
+ });
12650
+ }
12651
+ /**
11900
12652
  * Execute a feature's declared write to a gateway-managed shared file: parse
11901
12653
  * the existing content, merge the patch under the feature's declared policy,
11902
12654
  * and serialize it back over the existing content (see
11903
- * {@link serializeSharedConfig}, which keeps a JSONC file's comments and
11904
- * formatting outside the spans the merge actually changed). Throws when the
12655
+ * {@link serializeSharedConfigFile}, which adds the keys the file's declaration
12656
+ * ensures and keeps a JSONC file's comments and formatting outside the spans
12657
+ * the merge actually changed). Throws when the
11905
12658
  * file or feature is undeclared, when a
11906
12659
  * `replace-owned-keys` patch strays outside its owned keys, or when the
11907
12660
  * feature's policy is `custom` (those calls go to the named policy function
11908
12661
  * instead).
11909
12662
  */
11910
- function applySharedConfigPatch({ fileKey, feature, existingContent, patch, filePath }) {
12663
+ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, filePath, logger }) {
11911
12664
  const declaration = SHARED_CONFIG_OWNERSHIP[fileKey];
11912
12665
  if (!declaration) throw new Error(`Shared config file '${fileKey}' has no SHARED_CONFIG_OWNERSHIP declaration; declare its writers and policies before writing it through the gateway.`);
11913
12666
  const policy = declaration.features[feature];
@@ -11928,10 +12681,12 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11928
12681
  patch
11929
12682
  });
11930
12683
  for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
11931
- return serializeSharedConfig({
11932
- format: declaration.format,
12684
+ return serializeDeclaredSharedConfig({
12685
+ declaration,
11933
12686
  document,
11934
- existingContent
12687
+ existingContent,
12688
+ filePath: filePath ?? fileKey,
12689
+ logger
11935
12690
  });
11936
12691
  }
11937
12692
  const merged = mergeSharedConfigDeep({
@@ -11939,10 +12694,12 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11939
12694
  patch
11940
12695
  });
11941
12696
  for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
11942
- return serializeSharedConfig({
11943
- format: declaration.format,
12697
+ return serializeDeclaredSharedConfig({
12698
+ declaration,
11944
12699
  document: merged,
11945
- existingContent
12700
+ existingContent,
12701
+ filePath: filePath ?? fileKey,
12702
+ logger
11946
12703
  });
11947
12704
  }
11948
12705
  const READ_TOOL_NAME = "Read";
@@ -12486,7 +13243,10 @@ var ChecksProcessor = class extends FeatureProcessor {
12486
13243
  const factory = this.getFactory(this.toolTarget);
12487
13244
  const paths = factory.class.getSettablePaths({ global: this.global });
12488
13245
  const baseDir = (0, node_path.join)(this.outputRoot, paths.relativeDirPath);
12489
- const checkFilePaths = await findFilesByGlobs((0, node_path.join)(baseDir, factory.meta.filePattern));
13246
+ const checkFilePaths = await findFilesByGlobs(factory.meta.filePattern, {
13247
+ cwd: baseDir,
13248
+ followSymbolicLinks: !forDeletion
13249
+ });
12490
13250
  const toRelativeFilePath = (path) => (0, node_path.relative)(baseDir, path);
12491
13251
  if (forDeletion) {
12492
13252
  const toolChecks = checkFilePaths.map((path) => factory.class.forDeletion({
@@ -13258,7 +14018,7 @@ var AugmentcodeCommand = class AugmentcodeCommand extends ToolCommand {
13258
14018
  */
13259
14019
  static async loadAdditionalImportFiles({ outputRoot = process.cwd(), global = false, logger } = {}) {
13260
14020
  const rootDir = (0, node_path.join)(outputRoot, AUGMENTCODE_AGENTS_COMMANDS_DIR_PATH);
13261
- const filePaths = await findFilesByGlobs((0, node_path.join)(rootDir, "**", "*.md"));
14021
+ const filePaths = await findFilesByGlobs("**/*.md", { cwd: rootDir });
13262
14022
  const imported = (await Promise.all(filePaths.map(async (filePath) => {
13263
14023
  const relativePath = (0, node_path.relative)(rootDir, filePath);
13264
14024
  if (relativePath.startsWith("..") || (0, node_path.isAbsolute)(relativePath)) {
@@ -13295,33 +14055,6 @@ var AugmentcodeCommand = class AugmentcodeCommand extends ToolCommand {
13295
14055
  }
13296
14056
  };
13297
14057
  //#endregion
13298
- //#region src/constants/claudecode-paths.ts
13299
- /**
13300
- * Claude Code configuration-layout conventions.
13301
- *
13302
- * Single source of truth for where Claude Code expects its files
13303
- * (directories, file names, scope-specific paths). Every feature module
13304
- * (rules, commands, skills, subagents, ignore, mcp, permissions, hooks)
13305
- * and the gitignore entry registry import from here, so a change in the
13306
- * Claude Code conventions is a change to this file only.
13307
- */
13308
- /** Root directory for Claude Code configuration, relative to the scope root. */
13309
- const CLAUDECODE_DIR = ".claude";
13310
- const CLAUDECODE_RULE_FILE_NAME = "CLAUDE.md";
13311
- const CLAUDECODE_LOCAL_RULE_FILE_NAME = "CLAUDE.local.md";
13312
- /** Modular rules directory name under `.claude/` (current format). */
13313
- const CLAUDECODE_RULES_DIR_NAME = "rules";
13314
- /** Memories directory name under `.claude/` (legacy format). */
13315
- const CLAUDECODE_MEMORIES_DIR_NAME = "memories";
13316
- const CLAUDECODE_COMMANDS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "commands");
13317
- const CLAUDECODE_AGENTS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "agents");
13318
- const CLAUDECODE_SKILLS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "skills");
13319
- const CLAUDECODE_SCHEDULED_TASKS_DIR_PATH = (0, node_path.join)(CLAUDECODE_DIR, "scheduled-tasks");
13320
- const CLAUDECODE_SETTINGS_FILE_NAME = "settings.json";
13321
- const CLAUDECODE_SETTINGS_LOCAL_FILE_NAME = "settings.local.json";
13322
- const CLAUDECODE_MCP_FILE_NAME = ".mcp.json";
13323
- const CLAUDECODE_GLOBAL_MCP_FILE_NAME = ".claude.json";
13324
- //#endregion
13325
14058
  //#region src/features/commands/claudecode-command.ts
13326
14059
  const ClaudecodeCommandFrontmatterSchema = zod_mini.z.looseObject({
13327
14060
  description: zod_mini.z.optional(zod_mini.z.string()),
@@ -13969,7 +14702,7 @@ function commandSlug(relativeFilePath) {
13969
14702
  * it up as a regular orphan.
13970
14703
  */
13971
14704
  async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
13972
- return (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
14705
+ return (await Promise.all(inputRoots.map((root) => findFilesByGlobs("**/*.md", { cwd: (0, node_path.join)(root, COMMANDS_FEATURE_SUBDIR) })))).flat().some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
13973
14706
  }
13974
14707
  //#endregion
13975
14708
  //#region src/features/commands/devin-command.ts
@@ -14932,6 +15665,41 @@ function toStringMetadata(metadata) {
14932
15665
  return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, stringifyValue(value)]));
14933
15666
  }
14934
15667
  /**
15668
+ * The `agentsskills` block with the three Agent Skills standard fields resolved
15669
+ * against the root-level rulesync defaults (a defined section value wins).
15670
+ *
15671
+ * Every writer of an Agent Skills `SKILL.md` — the native target, Hermes Agent
15672
+ * and the simulated `agentsmd` — reads the block through this helper so a
15673
+ * root-authored value goes through exactly the same normalization and
15674
+ * spec-violation reporting as a section value. `allowed-tools` has no root
15675
+ * counterpart and is carried through untouched.
15676
+ *
15677
+ * @returns The merged block. It is empty when neither the section nor any
15678
+ * root-level field is set, which `toSpecConformantAgentSkillFields` emits as
15679
+ * nothing.
15680
+ */
15681
+ function resolveAgentsSkillsSection(rulesyncFrontmatter) {
15682
+ const section = rulesyncFrontmatter.agentsskills;
15683
+ const license = resolveLicense({
15684
+ rootFrontmatter: rulesyncFrontmatter,
15685
+ section
15686
+ });
15687
+ const compatibility = resolveCompatibility({
15688
+ rootFrontmatter: rulesyncFrontmatter,
15689
+ section
15690
+ });
15691
+ const metadata = resolveMetadata({
15692
+ rootFrontmatter: rulesyncFrontmatter,
15693
+ section
15694
+ });
15695
+ return {
15696
+ ...section,
15697
+ ...license !== void 0 && { license },
15698
+ ...compatibility !== void 0 && { compatibility },
15699
+ ...metadata !== void 0 && { metadata }
15700
+ };
15701
+ }
15702
+ /**
14935
15703
  * Convert the rulesync `agentsskills` block into the shapes the specification
14936
15704
  * requires. Shared with `HermesagentSkill`, which writes the same fields to its
14937
15705
  * own skill location, so one rulesync input can never produce two different
@@ -15079,7 +15847,7 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
15079
15847
  const agentsSkillsFrontmatter = {
15080
15848
  name: rulesyncFrontmatter.name,
15081
15849
  description: rulesyncFrontmatter.description,
15082
- ...toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills)
15850
+ ...toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(rulesyncFrontmatter))
15083
15851
  };
15084
15852
  AgentsSkillsSkill.reportSpecViolations({
15085
15853
  outputRoot,
@@ -15187,7 +15955,7 @@ var HermesagentSkill = class HermesagentSkill extends AgentsSkillsSkill {
15187
15955
  }
15188
15956
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
15189
15957
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
15190
- const shared = toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills, { coerceMetadata: false });
15958
+ const shared = toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(rulesyncFrontmatter), { coerceMetadata: false });
15191
15959
  const hermes = rulesyncFrontmatter.hermesagent ?? {};
15192
15960
  const dirName = rulesyncSkill.getDirName();
15193
15961
  const frontmatter = {
@@ -17833,7 +18601,7 @@ var CommandsProcessor = class extends FeatureProcessor {
17833
18601
  const treeName = (0, node_path.basename)(sourceTree);
17834
18602
  const treeCommandsDirPath = (0, node_path.join)(treeName, COMMANDS_FEATURE_SUBDIR);
17835
18603
  const basePath = (0, node_path.join)(sourceTree, COMMANDS_FEATURE_SUBDIR);
17836
- const rulesyncCommandPaths = await directoryExistsStrict(basePath) ? await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md")) : [];
18604
+ const rulesyncCommandPaths = await directoryExistsStrict(basePath) ? await findFilesByGlobs("**/*.md", { cwd: basePath }) : [];
17837
18605
  return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
17838
18606
  outputRoot: treeParent,
17839
18607
  relativeDirPath: treeCommandsDirPath,
@@ -17866,7 +18634,10 @@ var CommandsProcessor = class extends FeatureProcessor {
17866
18634
  if (factory.meta.skipToolFileScan) return [];
17867
18635
  const paths = factory.class.getSettablePaths({ global: this.global });
17868
18636
  const outputRootFull = (0, node_path.join)(this.outputRoot, paths.relativeDirPath);
17869
- const commandFilePaths = await findFilesByGlobs(factory.meta.supportsSubdirectory ? (0, node_path.join)(outputRootFull, "**", `*.${factory.meta.extension}`) : (0, node_path.join)(outputRootFull, `*.${factory.meta.extension}`), { followSymbolicLinks: !forDeletion });
18637
+ const commandFilePaths = await findFilesByGlobs(factory.meta.supportsSubdirectory ? `**/*.${factory.meta.extension}` : `*.${factory.meta.extension}`, {
18638
+ cwd: outputRootFull,
18639
+ followSymbolicLinks: !forDeletion
18640
+ });
17870
18641
  if (forDeletion) {
17871
18642
  const toolCommands = commandFilePaths.map((path) => factory.class.forDeletion({
17872
18643
  outputRoot: this.outputRoot,
@@ -18215,52 +18986,6 @@ function compact(obj) {
18215
18986
  return result;
18216
18987
  }
18217
18988
  //#endregion
18218
- //#region src/utils/quote-value.ts
18219
- /**
18220
- * How much of a value read off disk a diagnostic quotes.
18221
- *
18222
- * Enough to recognize which entry is meant, and no more. A warning names the
18223
- * offending value so the reader can find it, but the values these warnings
18224
- * quote come from files rulesync did not write — a tool's own settings, a
18225
- * machine-local overrides file, a repository fetched from elsewhere — and they
18226
- * no longer stop at a terminal: they travel into a `--json` document another
18227
- * program parses and into an MCP result an agent reads as context. A command
18228
- * line or a header is the shape most likely to carry a credential, and a long
18229
- * value is the shape most likely to carry instructions aimed at the agent.
18230
- */
18231
- const MAX_QUOTED_VALUE_LENGTH = 60;
18232
- /**
18233
- * A short, quotable rendering of a value for a diagnostic.
18234
- *
18235
- * Serialized rather than interpolated, because an unquoted value is what lets a
18236
- * crafted one read as a second line; stripped of the control characters
18237
- * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
18238
- * bidirectional overrides); and truncated.
18239
- */
18240
- function quoteValueForWarning(value) {
18241
- return truncateText({
18242
- text: stripControlCharacters(serialize(value)),
18243
- maxLength: MAX_QUOTED_VALUE_LENGTH,
18244
- suffix: "…(truncated)"
18245
- });
18246
- }
18247
- function serialize(value) {
18248
- try {
18249
- return JSON.stringify(value, stripStrings) ?? String(value);
18250
- } catch {
18251
- return `[unserializable ${typeof value}]`;
18252
- }
18253
- }
18254
- /**
18255
- * Strip the control characters out of every string before `JSON.stringify`
18256
- * sees it, not only out of the document it produces: `JSON.stringify` escapes
18257
- * a C0 character into the six literal characters `\u001b`, which no later pass
18258
- * over the output can recognize as a control character again.
18259
- */
18260
- function stripStrings(_key, value) {
18261
- return typeof value === "string" ? stripControlCharacters(value) : value;
18262
- }
18263
- //#endregion
18264
18989
  //#region src/features/hooks/tool-hooks-converter.ts
18265
18990
  function isToolMatcherEntry(x) {
18266
18991
  if (x === null || typeof x !== "object") return false;
@@ -24847,6 +25572,17 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
24847
25572
  };
24848
25573
  }
24849
25574
  /**
25575
+ * The `fileMode: "local"` twin, `.claude/settings.local.json`: a settable
25576
+ * path only under that option, so the default `getSettablePaths` does not
25577
+ * report it, and the gateway would otherwise not know the file has a writer.
25578
+ */
25579
+ static getExtraSharedWritePaths() {
25580
+ return [{
25581
+ relativeDirPath: CLAUDECODE_DIR,
25582
+ relativeFilePath: CLAUDECODE_SETTINGS_LOCAL_FILE_NAME
25583
+ }];
25584
+ }
25585
+ /**
24850
25586
  * ClaudecodeIgnore uses settings.json (or settings.local.json), which can
24851
25587
  * include non-ignore settings. It should not be deleted by rulesync.
24852
25588
  *
@@ -24889,7 +25625,11 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
24889
25625
  outputRoot,
24890
25626
  relativeDirPath: paths.relativeDirPath,
24891
25627
  relativeFilePath: paths.relativeFilePath,
24892
- fileContent: JSON.stringify(jsonValue, null, 2),
25628
+ fileContent: serializeSharedConfigFile({
25629
+ fileKey: sharedConfigFileKey(paths),
25630
+ document: jsonValue,
25631
+ existingContent: existingFileContent
25632
+ }),
24893
25633
  validate: true
24894
25634
  });
24895
25635
  }
@@ -26265,18 +27005,48 @@ var AiassistantMcp = class AiassistantMcp extends ToolMcp {
26265
27005
  }
26266
27006
  };
26267
27007
  //#endregion
26268
- //#region src/features/mcp/amp-mcp.ts
26269
- const AMP_MCP_SERVERS_KEY = "amp.mcpServers";
26270
- function parseAmpSettingsJsonc(fileContent) {
26271
- const errors = [];
26272
- const parsed = (0, jsonc_parser.parse)(fileContent || "{}", errors, { allowTrailingComma: true });
26273
- if (errors.length > 0) {
26274
- const details = errors.map((error) => `${(0, jsonc_parser.printParseErrorCode)(error.error)} at offset ${error.offset}`).join(", ");
26275
- throw new Error(`Failed to parse Amp settings: ${details}`);
27008
+ //#region src/features/shared/amp-settings.ts
27009
+ /**
27010
+ * Parse an Amp `settings.json` / `settings.jsonc` document.
27011
+ *
27012
+ * Shared by the MCP and permissions adapters because both read the very same
27013
+ * file. Two separately maintained copies drifted once already: the permissions
27014
+ * side was hardened while the MCP side kept calling `jsonc-parser` directly,
27015
+ * which left it importing MCP servers reachable only through an injected
27016
+ * `__proto__`.
27017
+ *
27018
+ * {@link parseJsonc} is the strict, sanitizing parser: it throws on any syntax
27019
+ * error instead of returning `jsonc-parser`'s best-effort value, and it
27020
+ * rebuilds every object from its own enumerable entries, so a root `__proto__`
27021
+ * cannot swap the returned object's prototype and `constructor` / `prototype`
27022
+ * never survive as own keys, at any depth.
27023
+ *
27024
+ * Removal is silent, matching every other tool-side adapter (opencode, kilo,
27025
+ * copilot). Reporting the removed keys through `droppedPollutionKeysError` is
27026
+ * reserved for the three files a user authors under `.rulesync/`, whose whole
27027
+ * purpose is to be turned into tool config — a key that vanishes there needs
27028
+ * explaining, whereas here the surrounding settings are the user's own file
27029
+ * and are left alone.
27030
+ *
27031
+ * Note the consequence for a root `__proto__`: sanitizing runs before the
27032
+ * plain-object check, so such a document parses successfully with the key
27033
+ * stripped rather than failing that check. That is deliberate — dropping the
27034
+ * one poisoned key keeps the user's unrelated settings — and matches the
27035
+ * sanitize-before-the-root-check design the shared config gateway documents.
27036
+ */
27037
+ function parseAmpSettings({ fileContent }) {
27038
+ let parsed;
27039
+ try {
27040
+ parsed = parseJsonc$6(fileContent || "{}");
27041
+ } catch (error) {
27042
+ throw new Error(`Failed to parse Amp settings: ${formatError(error)}`, { cause: error });
26276
27043
  }
26277
27044
  if (!isPlainObject$1(parsed)) throw new Error("Amp settings must be a JSON object");
26278
27045
  return parsed;
26279
27046
  }
27047
+ //#endregion
27048
+ //#region src/features/mcp/amp-mcp.ts
27049
+ const AMP_MCP_SERVERS_KEY = "amp.mcpServers";
26280
27050
  function filterMcpServers(mcpServers) {
26281
27051
  const filtered = {};
26282
27052
  if (!isRecord$1(mcpServers)) return filtered;
@@ -26295,7 +27065,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26295
27065
  json;
26296
27066
  constructor(params) {
26297
27067
  super(params);
26298
- this.json = parseAmpSettingsJsonc(this.fileContent);
27068
+ this.json = parseAmpSettings({ fileContent: this.fileContent });
26299
27069
  }
26300
27070
  getJson() {
26301
27071
  return structuredClone(this.json);
@@ -26339,7 +27109,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26339
27109
  const basePaths = this.getSettablePaths({ global });
26340
27110
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
26341
27111
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
26342
- const json = fileContent ? parseAmpSettingsJsonc(fileContent) : {};
27112
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
26343
27113
  const mcpServers = json[AMP_MCP_SERVERS_KEY];
26344
27114
  const newJson = {
26345
27115
  ...json,
@@ -26354,7 +27124,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26354
27124
  global
26355
27125
  });
26356
27126
  }
26357
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
27127
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
26358
27128
  const basePaths = this.getSettablePaths({ global });
26359
27129
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
26360
27130
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
@@ -26367,7 +27137,8 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26367
27137
  feature: "mcp",
26368
27138
  existingContent: fileContent ?? "",
26369
27139
  patch: { [AMP_MCP_SERVERS_KEY]: filterMcpServers(rulesyncMcp.getMcpServers()) },
26370
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
27140
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
27141
+ logger
26371
27142
  }),
26372
27143
  validate,
26373
27144
  global
@@ -26380,17 +27151,13 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26380
27151
  validate() {
26381
27152
  let json;
26382
27153
  try {
26383
- json = parseAmpSettingsJsonc(this.fileContent);
27154
+ json = parseAmpSettings({ fileContent: this.fileContent });
26384
27155
  } catch (error) {
26385
27156
  return {
26386
27157
  success: false,
26387
27158
  error: error instanceof Error ? error : new Error(String(error))
26388
27159
  };
26389
27160
  }
26390
- for (const key of Object.keys(json)) if (isPrototypePollutionKey(key)) return {
26391
- success: false,
26392
- error: /* @__PURE__ */ new Error(`Prototype pollution key "${key}" is not allowed`)
26393
- };
26394
27161
  const mcpServers = json[AMP_MCP_SERVERS_KEY];
26395
27162
  if (mcpServers === void 0) return {
26396
27163
  success: true,
@@ -26400,20 +27167,10 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26400
27167
  success: false,
26401
27168
  error: /* @__PURE__ */ new Error(`${AMP_MCP_SERVERS_KEY} must be a JSON object`)
26402
27169
  };
26403
- for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
26404
- if (isPrototypePollutionKey(serverName)) return {
26405
- success: false,
26406
- error: /* @__PURE__ */ new Error(`Server name "${serverName}" is a prototype pollution key and is not allowed`)
26407
- };
26408
- if (!isRecord$1(serverConfig)) return {
26409
- success: false,
26410
- error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
26411
- };
26412
- for (const key of Object.keys(serverConfig)) if (isPrototypePollutionKey(key)) return {
26413
- success: false,
26414
- error: /* @__PURE__ */ new Error(`Config key "${key}" in server "${serverName}" is a prototype pollution key and is not allowed`)
26415
- };
26416
- }
27170
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) if (!isRecord$1(serverConfig)) return {
27171
+ success: false,
27172
+ error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
27173
+ };
26417
27174
  return {
26418
27175
  success: true,
26419
27176
  error: null
@@ -27344,7 +28101,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
27344
28101
  json;
27345
28102
  constructor(params) {
27346
28103
  super(params);
27347
- this.json = this.fileContent !== void 0 ? parseJsonc$8(this.fileContent) : {};
28104
+ this.json = this.fileContent !== void 0 ? parseJsonc$6(this.fileContent) : {};
27348
28105
  }
27349
28106
  getJson() {
27350
28107
  return this.json;
@@ -27365,7 +28122,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
27365
28122
  validate
27366
28123
  });
27367
28124
  }
27368
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true }) {
28125
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger }) {
27369
28126
  const paths = this.getSettablePaths();
27370
28127
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
27371
28128
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
@@ -27378,7 +28135,8 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
27378
28135
  feature: "mcp",
27379
28136
  existingContent,
27380
28137
  patch: { servers: rulesyncMcp.getMcpServers() },
27381
- filePath
28138
+ filePath,
28139
+ logger
27382
28140
  }),
27383
28141
  validate
27384
28142
  });
@@ -29509,7 +30267,8 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29509
30267
  mcp: convertedMcp,
29510
30268
  tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
29511
30269
  },
29512
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
30270
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
30271
+ logger
29513
30272
  }),
29514
30273
  validate
29515
30274
  });
@@ -29527,7 +30286,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29527
30286
  *
29528
30287
  * @see https://kilo.ai/docs/automate/mcp/using-in-kilo-code
29529
30288
  */
29530
- static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false }) {
30289
+ static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false, logger }) {
29531
30290
  const basePaths = this.getSettablePaths({ global });
29532
30291
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
29533
30292
  let fileContent = null;
@@ -29554,7 +30313,8 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29554
30313
  feature: "rules",
29555
30314
  existingContent: fileContent ?? "",
29556
30315
  patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : void 0 },
29557
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
30316
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
30317
+ logger
29558
30318
  }),
29559
30319
  validate
29560
30320
  });
@@ -29567,7 +30327,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29567
30327
  }, null, 2) });
29568
30328
  }
29569
30329
  validate() {
29570
- const json = parseJsonc$8(this.fileContent || "{}");
30330
+ const json = parseJsonc$6(this.fileContent || "{}");
29571
30331
  const result = KiloConfigSchema.safeParse(json);
29572
30332
  if (!result.success) return {
29573
30333
  success: false,
@@ -30574,7 +31334,8 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30574
31334
  mcp: convertedMcp,
30575
31335
  tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
30576
31336
  },
30577
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
31337
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
31338
+ logger
30578
31339
  }),
30579
31340
  validate
30580
31341
  });
@@ -30595,7 +31356,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30595
31356
  * @see https://opencode.ai/docs/rules/
30596
31357
  * @see https://opencode.ai/docs/config/
30597
31358
  */
30598
- static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false }) {
31359
+ static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false, logger }) {
30599
31360
  const basePaths = this.getSettablePaths({ global });
30600
31361
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
30601
31362
  const configDirPrefix = `${toPosixPath(basePaths.relativeDirPath).replace(/\/+$/, "")}/`;
@@ -30627,7 +31388,8 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30627
31388
  feature: "rules",
30628
31389
  existingContent: fileContent ?? "",
30629
31390
  patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : void 0 },
30630
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
31391
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
31392
+ logger
30631
31393
  }),
30632
31394
  validate
30633
31395
  });
@@ -30643,7 +31405,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30643
31405
  }, null, 2) });
30644
31406
  }
30645
31407
  validate() {
30646
- const json = parseJsonc$8(this.fileContent || "{}");
31408
+ const json = parseJsonc$6(this.fileContent || "{}");
30647
31409
  const result = OpencodeConfigSchema.safeParse(json);
30648
31410
  if (!result.success) return {
30649
31411
  success: false,
@@ -33115,16 +33877,6 @@ function isCanonicalAmpEntry(entry) {
33115
33877
  const keys = Object.keys(matches);
33116
33878
  return keys.length === 0 || keys.length === 1 && typeof matches.cmd === "string";
33117
33879
  }
33118
- function parseAmpSettings(fileContent) {
33119
- const errors = [];
33120
- const parsed = (0, jsonc_parser.parse)(fileContent || "{}", errors, { allowTrailingComma: true });
33121
- if (errors.length > 0) {
33122
- const details = errors.map((error) => `${(0, jsonc_parser.printParseErrorCode)(error.error)} at offset ${error.offset}`).join(", ");
33123
- throw new Error(`Failed to parse Amp settings: ${details}`);
33124
- }
33125
- if (!isPlainObject$1(parsed)) throw new Error("Amp settings must be a JSON object");
33126
- return parsed;
33127
- }
33128
33880
  function toDisableList(value) {
33129
33881
  if (!Array.isArray(value)) return [];
33130
33882
  return value.filter((entry) => typeof entry === "string");
@@ -33240,7 +33992,7 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33240
33992
  const basePaths = AmpPermissions.getSettablePaths({ global });
33241
33993
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
33242
33994
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
33243
- const json = fileContent ? parseAmpSettings(fileContent) : {};
33995
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
33244
33996
  const newJson = {
33245
33997
  ...json,
33246
33998
  [AMP_TOOLS_DISABLE_KEY]: toDisableList(json[AMP_TOOLS_DISABLE_KEY])
@@ -33253,11 +34005,11 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33253
34005
  validate
33254
34006
  });
33255
34007
  }
33256
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false }) {
34008
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
33257
34009
  const basePaths = AmpPermissions.getSettablePaths({ global });
33258
34010
  const jsonDir = (0, node_path.join)(outputRoot, basePaths.relativeDirPath);
33259
34011
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
33260
- const json = fileContent ? parseAmpSettings(fileContent) : {};
34012
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
33261
34013
  const config = rulesyncPermissions.getJson();
33262
34014
  const { disable, permissions } = convertRulesyncToAmp(config);
33263
34015
  const override = config.amp;
@@ -33284,13 +34036,14 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33284
34036
  feature: "permissions",
33285
34037
  existingContent: fileContent ?? "",
33286
34038
  patch,
33287
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
34039
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
34040
+ logger
33288
34041
  }),
33289
34042
  validate: true
33290
34043
  });
33291
34044
  }
33292
34045
  toRulesyncPermissions() {
33293
- const json = parseAmpSettings(this.getFileContent());
34046
+ const json = parseAmpSettings({ fileContent: this.getFileContent() });
33294
34047
  const allPermissions = toPermissionsList(json[AMP_PERMISSIONS_KEY]);
33295
34048
  const canonicalEntries = allPermissions.filter(isCanonicalAmpEntry);
33296
34049
  const overrideEntries = allPermissions.filter((entry) => !isCanonicalAmpEntry(entry));
@@ -33310,7 +34063,7 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33310
34063
  }
33311
34064
  validate() {
33312
34065
  try {
33313
- const json = parseAmpSettings(this.fileContent);
34066
+ const json = parseAmpSettings({ fileContent: this.fileContent });
33314
34067
  const disable = json[AMP_TOOLS_DISABLE_KEY];
33315
34068
  if (disable !== void 0 && !Array.isArray(disable)) return {
33316
34069
  success: false,
@@ -35360,15 +36113,15 @@ const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNE
35360
36113
  * passthrough must not carry. `permissions` and `sandbox` have their own merge
35361
36114
  * branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
35362
36115
  * respectively), `permission` is rulesync's own canonical tool-scoped block
35363
- * rather than a settings key, `$schema` is an editor pointer rather than a
35364
- * Claude Code setting, and the rest belong to the other features writing this
35365
- * shared file.
36116
+ * rather than a settings key, the keys the gateway ensures on the file
36117
+ * (`$schema`) are editor pointers rather than Claude Code settings, and the
36118
+ * rest belong to the other features writing this shared file.
35366
36119
  */
35367
36120
  const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
35368
36121
  "permission",
35369
36122
  "permissions",
35370
36123
  "sandbox",
35371
- "$schema",
36124
+ ...Object.keys(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.ensuredKeys ?? {}),
35372
36125
  ...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
35373
36126
  ]);
35374
36127
  /**
@@ -35740,7 +36493,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
35740
36493
  deny,
35741
36494
  logger
35742
36495
  });
35743
- const fileContent = JSON.stringify(merged, null, 2);
36496
+ const fileContent = serializeSharedConfigFile({
36497
+ fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
36498
+ document: merged,
36499
+ existingContent
36500
+ });
35744
36501
  return new ClaudecodePermissions({
35745
36502
  outputRoot,
35746
36503
  relativeDirPath: paths.relativeDirPath,
@@ -36767,7 +37524,7 @@ var CopilotPermissions = class CopilotPermissions extends ToolPermissions {
36767
37524
  validate
36768
37525
  });
36769
37526
  }
36770
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions }) {
37527
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger }) {
36771
37528
  const paths = CopilotPermissions.getSettablePaths();
36772
37529
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
36773
37530
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
@@ -36787,7 +37544,8 @@ var CopilotPermissions = class CopilotPermissions extends ToolPermissions {
36787
37544
  feature: "permissions",
36788
37545
  existingContent,
36789
37546
  patch,
36790
- filePath
37547
+ filePath,
37548
+ logger
36791
37549
  }),
36792
37550
  validate: true
36793
37551
  });
@@ -37507,6 +38265,18 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
37507
38265
  isDeletable() {
37508
38266
  return false;
37509
38267
  }
38268
+ /**
38269
+ * `config.toml` is dcode's file, not one rulesync owns: rulesync merges into
38270
+ * it when it exists but has no business bringing it into existence to hold
38271
+ * nothing. When no rule maps, `[shell]` is dropped and
38272
+ * `smolToml.stringify({})` leaves a lone newline, which would otherwise be
38273
+ * written as a fresh `~/.deepagents/config.toml` that says nothing. An
38274
+ * existing file is still rewritten as before, so user content is never
38275
+ * dropped — the skip only applies when there is no file yet.
38276
+ */
38277
+ shouldSkipCreationWhenPayloadEmpty() {
38278
+ return true;
38279
+ }
37510
38280
  static getSettablePaths(_options) {
37511
38281
  return {
37512
38282
  relativeDirPath: DEEPAGENTS_DIR,
@@ -40439,7 +41209,8 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
40439
41209
  feature: "permissions",
40440
41210
  existingContent: fileContent ?? "",
40441
41211
  patch: { permission },
40442
- filePath: (0, node_path.join)(jsonDir, relativeFilePath)
41212
+ filePath: (0, node_path.join)(jsonDir, relativeFilePath),
41213
+ logger
40443
41214
  }),
40444
41215
  validate: true
40445
41216
  });
@@ -40465,7 +41236,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
40465
41236
  }
40466
41237
  validate() {
40467
41238
  try {
40468
- const json = parseJsonc$8(this.fileContent || "{}");
41239
+ const json = parseJsonc$6(this.fileContent || "{}");
40469
41240
  const result = OpencodePermissionsConfigSchema.safeParse(json);
40470
41241
  if (!result.success) return {
40471
41242
  success: false,
@@ -42564,18 +43335,20 @@ function pickSecurityPolicies(source, report) {
42564
43335
  * not emoji at all are named beside it: the angle brackets, the trigrams and
42565
43336
  * digrams, and the hexagrams of U+4DC0–U+4DFF.
42566
43337
  *
42567
- * The ranges are the standard ones rather than a lookup of the full property
42568
- * table, which Unicode revises with every release and which is not worth
42569
- * carrying for the one thing it is used for here — deciding when a label is
42570
- * long enough to wrap. Where a range is coarse it is coarse in the safe
42571
- * direction: the whole of U+1F000–U+1FAFF is counted as wide, which overstates
42572
- * the few narrow symbols in it, because overstating a width shortens a label
42573
- * that did not need it while understating one lets a label wrap. That is also
42574
- * why an emoji built from a chain of joiners is counted per component: two
42575
- * columns each rather than the two the whole chain draws.
42576
- *
42577
- * Ambiguous-width characters are counted as one column, which is what a
42578
- * terminal running a Latin font does.
43338
+ * The ranges are the standard blocks rather than the full `East_Asian_Width`
43339
+ * property, because the wide characters sit in blocks and a block is coarse in
43340
+ * the safe direction: the whole of U+1F000–U+1FAFF is counted as wide, which
43341
+ * overstates the few narrow symbols in it, because overstating a width shortens
43342
+ * a label that did not need it while understating one lets a label wrap. That
43343
+ * is also why an emoji built from a chain of joiners is counted per component:
43344
+ * two columns each rather than the two the whole chain draws.
43345
+ *
43346
+ * The East Asian Ambiguous class is counted at two columns as well, by the
43347
+ * table below this one rather than by these ranges, and that table is the
43348
+ * whole of its property: the class is scattered among the Neutral characters
43349
+ * rather than gathered in blocks, so a block list over it would either miss
43350
+ * members or take in characters that are not wide anywhere. The reason for
43351
+ * counting it, and how the table is kept in step with Unicode, is given there.
42579
43352
  *
42580
43353
  * Two of the ranges are here for a narrower reason: a name the skill prompt can
42581
43354
  * offer may not be counted narrower here than the prompt's own renderer counts
@@ -42613,6 +43386,64 @@ function pickSecurityPolicies(source, report) {
42613
43386
  */
42614
43387
  const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|[\u2329\u232a\u2630-\u2637\u268a-\u268f\u4dc0-\u4dff]|[\u1100-\u11ff\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\ud7b0-\ud7fb\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]|[\u{16fe0}-\u{16ff6}]|[\u{17000}-\u{18dff}]|[\u{1aff0}-\u{1b2ff}]|[\u{1f000}-\u{1faff}]|[\u{20000}-\u{3fffd}]/u;
42615
43388
  /**
43389
+ * The East Asian Ambiguous class of UAX #11: the characters a terminal draws
43390
+ * one column wide under a Latin font and two columns wide where it is set to
43391
+ * draw the ambiguous class wide, which is a common setting in CJK locales. The
43392
+ * box-drawing characters, the geometric shapes, the Greek and Cyrillic
43393
+ * alphabets, and the accented letters of the Latin-1 supplement are all here.
43394
+ *
43395
+ * Counted at two columns, which is the wider of the two answers, because the
43396
+ * two ways of being wrong are not the same size. Overstating a width shortens
43397
+ * a label that did not need it: a name carrying a Greek letter or an accented
43398
+ * letter is cut a little earlier on a Latin terminal than it had to be.
43399
+ * Understating one lets a forged row wrap: a name of sixty box-drawing
43400
+ * characters and `● pdf-tools` measures 71 columns at one column apiece,
43401
+ * inside the prompt's budget, and draws at 132 in a terminal that draws the
43402
+ * class wide, so the terminal breaks the row itself and puts `● pdf-tools` at
43403
+ * the left margin of a continuation line that carries no pointer and no
43404
+ * checkbox.
43405
+ *
43406
+ * The prompt's own renderer, `fast-string-width`, counts the class at one
43407
+ * column and never sees the wrap coming either; the terminal is what breaks
43408
+ * the row, and only the budget can keep the row short enough not to be broken.
43409
+ *
43410
+ * One width model rather than two — a wide one for bounding an untrusted name
43411
+ * and a narrow one for laying out the tool's own text — because the second
43412
+ * model would buy a column or two of alignment in the tool's own output at the
43413
+ * price of two measurements that have to be kept from being confused for each
43414
+ * other. Where this is used to lay out text of the tool's own, the cost of the
43415
+ * single model is a line cut a little earlier than it needed to be.
43416
+ *
43417
+ * The ranges are the `East_Asian_Width=A` property of Unicode 17.0, taken from
43418
+ * the table `get-east-asian-width` 1.6.0 carries and merged where two are
43419
+ * adjacent. The table is written out here rather than read from the package so
43420
+ * that the one lookup adds no dependency of its own, and the drift test in
43421
+ * `display-width.test.ts` is what keeps it honest: it loads the package through
43422
+ * the prompt renderer that already depends on it, walks every code point, and
43423
+ * fails when this table and the property disagree in either direction. When
43424
+ * the renderer's copy moves to a newer Unicode, that test is what fails, and
43425
+ * the table is regenerated from the new property — its ranges, marks aside,
43426
+ * merged where adjacent and written as escapes — with the version named here.
43427
+ *
43428
+ * Three stretches of the property are left out because they never reach this
43429
+ * pattern: the combining diacritical marks of U+0300–U+036F and the variation
43430
+ * selectors of U+FE00–U+FE0F and U+E0100–U+E01EF are marks by category and are
43431
+ * counted by the mark rule before the width of a character is looked up.
43432
+ * Leaving them out is also what keeps the class from holding a combining
43433
+ * character, which `no-misleading-character-class` is there to catch, and they
43434
+ * are the one exception the drift test allows. U+00AD SOFT HYPHEN never reaches
43435
+ * this pattern either — it is a format character, and the zero-width rule
43436
+ * counts it at nothing first — but it is kept, as the property has it, so that
43437
+ * the test holds the table to the property exactly rather than to a list of
43438
+ * exceptions. The private use areas are in it, as the property says they are: a
43439
+ * name is free to carry them and a terminal is free to draw them wide.
43440
+ *
43441
+ * Escapes rather than the characters themselves, for the reason given above.
43442
+ * Exported for the drift test alone; the width of a string is asked for
43443
+ * through `displayWidthOf`.
43444
+ */
43445
+ const AMBIGUOUS_CHARACTERS_PATTERN = /[\u00a1\u00a4\u00a7-\u00a8\u00aa\u00ad-\u00ae\u00b0-\u00b4\u00b6-\u00ba\u00bc-\u00bf\u00c6\u00d0\u00d7-\u00d8\u00de-\u00e1\u00e6\u00e8-\u00ea\u00ec-\u00ed\u00f0\u00f2-\u00f3\u00f7-\u00fa\u00fc\u00fe\u0101\u0111\u0113\u011b\u0126-\u0127\u012b\u0131-\u0133\u0138\u013f-\u0142\u0144\u0148-\u014b\u014d\u0152-\u0153\u0166-\u0167\u016b\u01ce\u01d0\u01d2\u01d4\u01d6\u01d8\u01da\u01dc\u0251\u0261\u02c4\u02c7\u02c9-\u02cb\u02cd\u02d0\u02d8-\u02db\u02dd\u02df\u0391-\u03a1\u03a3-\u03a9\u03b1-\u03c1\u03c3-\u03c9\u0401\u0410-\u044f\u0451\u2010\u2013-\u2016\u2018-\u2019\u201c-\u201d\u2020-\u2022\u2024-\u2027\u2030\u2032-\u2033\u2035\u203b\u203e\u2074\u207f\u2081-\u2084\u20ac\u2103\u2105\u2109\u2113\u2116\u2121-\u2122\u2126\u212b\u2153-\u2154\u215b-\u215e\u2160-\u216b\u2170-\u2179\u2189\u2190-\u2199\u21b8-\u21b9\u21d2\u21d4\u21e7\u2200\u2202-\u2203\u2207-\u2208\u220b\u220f\u2211\u2215\u221a\u221d-\u2220\u2223\u2225\u2227-\u222c\u222e\u2234-\u2237\u223c-\u223d\u2248\u224c\u2252\u2260-\u2261\u2264-\u2267\u226a-\u226b\u226e-\u226f\u2282-\u2283\u2286-\u2287\u2295\u2299\u22a5\u22bf\u2312\u2460-\u24e9\u24eb-\u254b\u2550-\u2573\u2580-\u258f\u2592-\u2595\u25a0-\u25a1\u25a3-\u25a9\u25b2-\u25b3\u25b6-\u25b7\u25bc-\u25bd\u25c0-\u25c1\u25c6-\u25c8\u25cb\u25ce-\u25d1\u25e2-\u25e5\u25ef\u2605-\u2606\u2609\u260e-\u260f\u261c\u261e\u2640\u2642\u2660-\u2661\u2663-\u2665\u2667-\u266a\u266c-\u266d\u266f\u269e-\u269f\u26bf\u26c6-\u26cd\u26cf-\u26d3\u26d5-\u26e1\u26e3\u26e8-\u26e9\u26eb-\u26f1\u26f4\u26f6-\u26f9\u26fb-\u26fc\u26fe-\u26ff\u273d\u2776-\u277f\u2b56-\u2b59\u3248-\u324f\ue000-\uf8ff\ufffd\u{1f100}-\u{1f10a}\u{1f110}-\u{1f12d}\u{1f130}-\u{1f169}\u{1f170}-\u{1f18d}\u{1f18f}-\u{1f190}\u{1f19b}-\u{1f1ac}\u{f0000}-\u{ffffd}\u{100000}-\u{10fffd}]/u;
43446
+ /**
42616
43447
  * U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
42617
43448
  * character before it to be drawn as an emoji — that is, in two columns rather
42618
43449
  * than one. Counting it as a column of its own is how that promotion is paid
@@ -42620,7 +43451,12 @@ const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|
42620
43451
  * draws rather than the one the heart alone would.
42621
43452
  */
42622
43453
  const EMOJI_PRESENTATION_SELECTOR = "️";
42623
- /** Combining marks are drawn on top of the character before them, not beside it. */
43454
+ /**
43455
+ * Combining marks are drawn on top of the character before them, not beside it.
43456
+ *
43457
+ * Exported for the drift test on the ambiguous table, which has to leave out
43458
+ * exactly the characters this rule catches first.
43459
+ */
42624
43460
  const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
42625
43461
  /** The characters that take no width at all, marks aside. */
42626
43462
  const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
@@ -42666,16 +43502,28 @@ function isCombiningMark(character) {
42666
43502
  return character !== EMOJI_PRESENTATION_SELECTOR && COMBINING_MARK_PATTERN.test(character);
42667
43503
  }
42668
43504
  /**
43505
+ * A lone surrogate, which is no character at all: a string can carry one — a
43506
+ * `"\ud800"` escape in JSON is enough — and the encoder that writes stdout
43507
+ * replaces it with U+FFFD on the way out, so the replacement character is what
43508
+ * the terminal draws and what has to be measured. U+FFFD is East Asian
43509
+ * Ambiguous, so the difference is a column apiece: 72 of them measured as
43510
+ * themselves fit a 72-column budget and are drawn in 144.
43511
+ */
43512
+ const LONE_SURROGATE_PATTERN = /\p{Cs}/u;
43513
+ /** What the stdout encoder writes in place of a lone surrogate. */
43514
+ const REPLACEMENT_CHARACTER = "�";
43515
+ /**
42669
43516
  * The width of one character, given how many marks already sit on the character
42670
43517
  * before it.
42671
43518
  */
42672
43519
  function widthInContext(params) {
42673
- const { character, precedingMarks } = params;
43520
+ const { precedingMarks } = params;
43521
+ const character = LONE_SURROGATE_PATTERN.test(params.character) ? REPLACEMENT_CHARACTER : params.character;
42674
43522
  if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
42675
43523
  if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
42676
43524
  if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
42677
43525
  if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
42678
- return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
43526
+ return WIDE_CHARACTERS_PATTERN.test(character) || AMBIGUOUS_CHARACTERS_PATTERN.test(character) ? 2 : 1;
42679
43527
  }
42680
43528
  /**
42681
43529
  * How many terminal columns a string occupies.
@@ -42699,6 +43547,20 @@ function displayWidthOf(text) {
42699
43547
  /** The mark a cut string ends in. */
42700
43548
  const SHORTENING_ELLIPSIS = "…";
42701
43549
  /**
43550
+ * The columns a cut string is drawn in at the very least: what is left when
43551
+ * the budget has room for the mark of the cut and nothing before it. Two, since
43552
+ * the ellipsis is itself East Asian Ambiguous and is measured the way every
43553
+ * other such character is, so that a shortened label is not wider than it was
43554
+ * measured to be on the terminal the measurement is for.
43555
+ *
43556
+ * Exported because a caller composing several shortened pieces into one line
43557
+ * has to leave room for it to decide which of them gives way, and the width of
43558
+ * the mark is this module's business rather than something to be counted again
43559
+ * at the other end. Leaving room for it is not what bounds the line: cutting
43560
+ * the composed line is.
43561
+ */
43562
+ const ELLIPSIS_WIDTH = displayWidthOf(SHORTENING_ELLIPSIS);
43563
+ /**
42702
43564
  * Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
42703
43565
  *
42704
43566
  * The ellipsis is paid for out of the budget rather than added on top of it, so
@@ -42709,7 +43571,7 @@ const SHORTENING_ELLIPSIS = "…";
42709
43571
  function shortenToWidth(params) {
42710
43572
  const { text, budget } = params;
42711
43573
  if (displayWidthOf(text) <= budget) return text;
42712
- const target = budget - 1;
43574
+ const target = budget - ELLIPSIS_WIDTH;
42713
43575
  let width = 0;
42714
43576
  let marks = 0;
42715
43577
  const kept = [];
@@ -45647,7 +46509,7 @@ var AgentsmdSkill = class AgentsmdSkill extends SimulatedSkill {
45647
46509
  const relativeDirPath = this.getSettablePaths().relativeDirPath;
45648
46510
  const frontmatter = {
45649
46511
  ...defaults.frontmatter,
45650
- ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills)
46512
+ ...toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(params.rulesyncSkill.getFrontmatter()))
45651
46513
  };
45652
46514
  AgentsSkillsSkill.reportSpecViolations({
45653
46515
  outputRoot: params.outputRoot ?? process.cwd(),
@@ -45773,13 +46635,25 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
45773
46635
  const settablePaths = RovodevSkill.getSettablePaths({ global });
45774
46636
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
45775
46637
  const rovodevSection = rulesyncFrontmatter.rovodev;
46638
+ const license = resolveLicense({
46639
+ rootFrontmatter: rulesyncFrontmatter,
46640
+ section: rovodevSection
46641
+ });
46642
+ const compatibility = resolveCompatibility({
46643
+ rootFrontmatter: rulesyncFrontmatter,
46644
+ section: rovodevSection
46645
+ });
46646
+ const metadata = resolveMetadata({
46647
+ rootFrontmatter: rulesyncFrontmatter,
46648
+ section: rovodevSection
46649
+ });
45776
46650
  const rovodevFrontmatter = {
45777
46651
  name: rulesyncFrontmatter.name,
45778
46652
  description: rulesyncFrontmatter.description,
45779
46653
  ...rovodevSection?.["allowed-tools"] !== void 0 && { "allowed-tools": rovodevSection["allowed-tools"] },
45780
- ...rovodevSection?.license !== void 0 && { license: rovodevSection.license },
45781
- ...rovodevSection?.compatibility !== void 0 && { compatibility: rovodevSection.compatibility },
45782
- ...rovodevSection?.metadata !== void 0 && { metadata: rovodevSection.metadata }
46654
+ ...license !== void 0 && { license },
46655
+ ...compatibility !== void 0 && { compatibility },
46656
+ ...metadata !== void 0 && { metadata }
45783
46657
  };
45784
46658
  return new RovodevSkill({
45785
46659
  outputRoot,
@@ -45961,7 +46835,12 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45961
46835
  composed: file.composed
45962
46836
  })) dirHasChanges = true;
45963
46837
  }
45964
- if (!dirHasChanges) continue;
46838
+ if (!dirHasChanges) {
46839
+ if (!this.dryRun) {
46840
+ for (const file of otherFiles) if (file.fileMode !== void 0) await restoreMissingExecutableBit((0, node_path.join)(dirPath, file.relativeFilePathToDirPath), file.fileMode);
46841
+ }
46842
+ continue;
46843
+ }
45965
46844
  const relativeDir = aiDir.getRelativePathFromCwd();
45966
46845
  if (this.dryRun) {
45967
46846
  this.logger.info(`[DRY RUN] Would create directory: ${stripControlCharacters(dirPath)}`);
@@ -45980,7 +46859,9 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45980
46859
  changedPaths.push((0, node_path.join)(relativeDir, mainFile.name));
45981
46860
  }
45982
46861
  for (const file of otherFiles) {
45983
- await writeFileBuffer((0, node_path.join)(dirPath, file.relativeFilePathToDirPath), file.fileBuffer);
46862
+ const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
46863
+ await writeFileBuffer(filePath, file.fileBuffer);
46864
+ if (file.fileMode !== void 0) await applyFileMode(filePath, file.fileMode);
45984
46865
  changedPaths.push((0, node_path.join)(relativeDir, file.relativeFilePathToDirPath));
45985
46866
  }
45986
46867
  }
@@ -45998,7 +46879,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45998
46879
  async removeOrphanAiDirs(existingDirs, generatedDirs) {
45999
46880
  const generatedPaths = new Set(generatedDirs.map((d) => d.getDirPath()));
46000
46881
  const orphanPaths = /* @__PURE__ */ new Set();
46001
- const quotedOutputRoot = JSON.stringify(stripControlCharacters(this.outputRoot));
46882
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
46002
46883
  for (const aiDir of existingDirs) {
46003
46884
  const dirPath = aiDir.getDirPath();
46004
46885
  const { verdict, root } = locateInOwnRoot({
@@ -46006,14 +46887,14 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46006
46887
  dirPath,
46007
46888
  outputRoot: this.outputRoot
46008
46889
  });
46009
- const quotedDirPath = JSON.stringify(stripControlCharacters(dirPath));
46010
- const quotedRoot = JSON.stringify(stripControlCharacters(root));
46890
+ const quotedDirPath = quoteForLog(dirPath);
46891
+ const quotedRoot = quoteForLog(root);
46011
46892
  if (verdict === "root-outside") {
46012
46893
  this.logger.warn(`Refusing to delete ${quotedDirPath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
46013
46894
  continue;
46014
46895
  }
46015
46896
  if (!aiDir.ownsDirTree()) {
46016
- if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${JSON.stringify(stripControlCharacters(aiDir.getDirName()))}: ${quotedDirPath} is a shared root, not a directory of its own`);
46897
+ if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${quoteForLog(aiDir.getDirName())}: ${quotedDirPath} is a shared root, not a directory of its own`);
46017
46898
  else this.logger.warn(`Refusing to delete ${quotedDirPath}: it does not own that directory, and it is not the shared root ${quotedRoot} it was found in either`);
46018
46899
  continue;
46019
46900
  }
@@ -46029,6 +46910,126 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46029
46910
  });
46030
46911
  }
46031
46912
  /**
46913
+ * Remove the files left inside a directory this run still generates, but
46914
+ * which the run no longer writes.
46915
+ *
46916
+ * The directory sweep above cannot see them: it removes a directory that no
46917
+ * longer corresponds to any generated entry, and never looks inside one that
46918
+ * does. So deleting a companion file from a source directory that is
46919
+ * otherwise kept left the generated copy in place — and, because change
46920
+ * detection compares only the files the run will write, the run reported
46921
+ * itself up to date while an agent went on reading the stale file. The same
46922
+ * gap left any file that was never rulesync's sitting inside a directory the
46923
+ * user now believes rulesync owns.
46924
+ *
46925
+ * Only a directory that owns its whole tree is swept, and only when this run
46926
+ * generated it. That is the same claim the directory sweep already acts on,
46927
+ * one level down: a directory whose entry disappears is deleted outright,
46928
+ * companion files and all, so a file inside one whose entry is still here and
46929
+ * which no source produces is stale by exactly the same reasoning.
46930
+ *
46931
+ * Two kinds of file are left alone:
46932
+ *
46933
+ * - **Hidden entries.** The loader does carry a hidden companion — a
46934
+ * `.env.example` beside the script that reads it is skill content — so a
46935
+ * hidden file here may be rulesync's. But a hidden name is also where a
46936
+ * user's own files live: a `.gitkeep`, a `.env` with real values in it. The
46937
+ * sweep cannot tell the two apart by name, and deleting the user's is the
46938
+ * worse mistake, so a stale hidden companion is the one leftover it
46939
+ * knowingly keeps.
46940
+ * - **Symbolic links**, which the writer never creates. The walk neither
46941
+ * follows nor reports them, so a link is never removed and never resolved
46942
+ * into a deletion somewhere outside the tree.
46943
+ *
46944
+ * Nothing is swept at all by a run that could not read its sources in full.
46945
+ * `AiDir` drops a companion file it cannot open, and stops short of a subtree
46946
+ * it is denied or that runs past one of its bounds -- warning each time, but
46947
+ * carrying on, because a skill that is short one file is still worth writing.
46948
+ * The output copy of such a file is then indistinguishable here from a file
46949
+ * whose source was deleted, and the wrong guess deletes something the next
46950
+ * readable run would put straight back. So a shortfall anywhere in the run
46951
+ * calls the whole sweep off: it is the sweeps that are optional, not the
46952
+ * files.
46953
+ *
46954
+ * @param isClaimed - Whether some other target or feature in this run wrote
46955
+ * this exact path. A shared output root -- `.agents/skills/`, written by
46956
+ * several targets at once -- is a directory whose entry here lists only
46957
+ * *this* target's files, so without the run's own record a sibling's fresh
46958
+ * output reads as an orphan. Asked per path rather than per tree: a tree
46959
+ * claim covers the directory this sweep is looking inside of, and would
46960
+ * answer yes to every file in it.
46961
+ */
46962
+ async removeOrphanFilesInAiDirs({ generatedDirs, isClaimed }) {
46963
+ if (hasIncompleteCarriedFiles()) {
46964
+ warnOnceWithFallback(this.logger, "Not sweeping the files inside generated directories: this run could not read every file its sources carry, so a file it did not write may still be one it wants. The warnings above name what it could not read.");
46965
+ return 0;
46966
+ }
46967
+ const orphanPaths = /* @__PURE__ */ new Set();
46968
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
46969
+ for (const aiDir of generatedDirs) {
46970
+ const dirPath = aiDir.getDirPath();
46971
+ const { verdict, root } = locateInOwnRoot({
46972
+ aiDir,
46973
+ dirPath,
46974
+ outputRoot: this.outputRoot
46975
+ });
46976
+ const quotedDirPath = quoteForLog(dirPath);
46977
+ const quotedRoot = quoteForLog(root);
46978
+ if (verdict === "root-outside") {
46979
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
46980
+ continue;
46981
+ }
46982
+ if (!aiDir.ownsDirTree()) {
46983
+ if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${quoteForLog(aiDir.getDirName())}: ${quotedDirPath} is a shared root, not a directory of its own`);
46984
+ else this.logger.warn(`Refusing to sweep ${quotedDirPath}: it does not own that directory, and it is not the shared root ${quotedRoot} it was found in either`);
46985
+ continue;
46986
+ }
46987
+ if (verdict !== "inside") {
46988
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: it is not a directory inside ${quotedRoot}, the root it was found in`);
46989
+ continue;
46990
+ }
46991
+ try {
46992
+ await assertWritablePathInsideRoot({
46993
+ rootPath: this.outputRoot,
46994
+ targetPath: dirPath
46995
+ });
46996
+ } catch (error) {
46997
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
46998
+ continue;
46999
+ }
47000
+ const generatedNames = /* @__PURE__ */ new Set();
47001
+ const mainFile = aiDir.getMainFile();
47002
+ if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
47003
+ for (const file of aiDir.getOtherFiles()) generatedNames.add(toPosixPath(file.relativeFilePathToDirPath));
47004
+ const generatedNamesFolded = new Set([...generatedNames].map((name) => name.toLowerCase()));
47005
+ let existingNames;
47006
+ try {
47007
+ existingNames = await listFilePathsRecursively(dirPath, {
47008
+ followSymbolicLinks: false,
47009
+ includeHidden: false
47010
+ });
47011
+ } catch (error) {
47012
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
47013
+ continue;
47014
+ }
47015
+ for (const existingName of existingNames) {
47016
+ const posixName = toPosixPath(existingName);
47017
+ if (generatedNames.has(posixName)) continue;
47018
+ const filePath = (0, node_path.join)(dirPath, existingName);
47019
+ if (generatedNamesFolded.has(posixName.toLowerCase())) {
47020
+ this.logger.warn(`Refusing to delete ${quoteForLog(filePath)}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
47021
+ continue;
47022
+ }
47023
+ if (isClaimed(filePath)) continue;
47024
+ orphanPaths.add(filePath);
47025
+ }
47026
+ }
47027
+ return await this.deleteOrphanPaths({
47028
+ paths: orphanPaths,
47029
+ kind: "file"
47030
+ });
47031
+ }
47032
+ /**
46032
47033
  * Delete the paths the sweeps decided on, or report what a real run would
46033
47034
  * have deleted. Shared by both halves so the dry-run wording, the quoting of
46034
47035
  * a name that came off disk, and the count they return stay one behavior
@@ -46036,7 +47037,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46036
47037
  */
46037
47038
  async deleteOrphanPaths({ paths, kind }) {
46038
47039
  for (const targetPath of paths) {
46039
- const loggedPath = JSON.stringify(stripControlCharacters(targetPath));
47040
+ const loggedPath = quoteForLog(targetPath);
46040
47041
  if (this.dryRun) this.logger.info(`[DRY RUN] Would delete ${kind}: ${loggedPath}`);
46041
47042
  else {
46042
47043
  await (kind === "directory" ? removeDirectory(targetPath) : removeFile(targetPath));
@@ -46073,11 +47074,11 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46073
47074
  }
46074
47075
  const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => generatedPath.toLowerCase()));
46075
47076
  const orphanPaths = /* @__PURE__ */ new Set();
46076
- const quotedOutputRoot = JSON.stringify(stripControlCharacters(this.outputRoot));
47077
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
46077
47078
  for (const aiDir of existingFlatFiles) {
46078
47079
  const filePath = aiDir.getFlatFilePath();
46079
47080
  if (filePath === void 0) {
46080
- this.logger.warn(`Refusing to sweep ${JSON.stringify(stripControlCharacters(aiDir.getDirName()))}: it owns a directory of its own, or names no file directly under the root it was found in`);
47081
+ this.logger.warn(`Refusing to sweep ${quoteForLog(aiDir.getDirName())}: it owns a directory of its own, or names no file directly under the root it was found in`);
46081
47082
  continue;
46082
47083
  }
46083
47084
  const dirPath = aiDir.getDirPath();
@@ -46086,8 +47087,8 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46086
47087
  dirPath,
46087
47088
  outputRoot: this.outputRoot
46088
47089
  });
46089
- const quotedFilePath = JSON.stringify(stripControlCharacters(filePath));
46090
- const quotedRoot = JSON.stringify(stripControlCharacters(root));
47090
+ const quotedFilePath = quoteForLog(filePath);
47091
+ const quotedRoot = quoteForLog(root);
46091
47092
  if (verdict === "root-outside") {
46092
47093
  this.logger.warn(`Refusing to delete ${quotedFilePath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
46093
47094
  continue;
@@ -46829,9 +47830,9 @@ async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
46829
47830
  if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
46830
47831
  const segments = realRelativeDirPath.split("/");
46831
47832
  const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
46832
- if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : JSON.stringify(stripControlCharacters(realRelativeDirPath))}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
47833
+ if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : quoteForLog(realRelativeDirPath)}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
46833
47834
  const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
46834
- if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
47835
+ if (excludedSegment !== void 0) return { reason: `it resolves inside ${quoteForLog(excludedSegment)}, which the nested scan excludes.` };
46835
47836
  return { realRelativeDirPath };
46836
47837
  }
46837
47838
  const ClaudecodeSkillFrontmatterSchema = zod_mini.z.looseObject({
@@ -46882,9 +47883,18 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
46882
47883
  "disable-model-invocation": resolvedDisableModelInvocation,
46883
47884
  "user-invocable": resolvedUserInvocable,
46884
47885
  paths: section.paths,
46885
- license: section.license,
46886
- compatibility: section.compatibility,
46887
- metadata: section.metadata
47886
+ license: resolveLicense({
47887
+ rootFrontmatter: rulesyncFrontmatter,
47888
+ section
47889
+ }),
47890
+ compatibility: resolveCompatibility({
47891
+ rootFrontmatter: rulesyncFrontmatter,
47892
+ section
47893
+ }),
47894
+ metadata: resolveMetadata({
47895
+ rootFrontmatter: rulesyncFrontmatter,
47896
+ section
47897
+ })
46888
47898
  };
46889
47899
  const frontmatter = {
46890
47900
  name: rulesyncFrontmatter.name,
@@ -47084,21 +48094,25 @@ var ClaudecodeSkill = class extends ToolSkill {
47084
48094
  */
47085
48095
  static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
47086
48096
  if (global) return [];
47087
- const root = toPosixPath(outputRoot);
48097
+ const skillsDirPath = toPosixPath(CLAUDECODE_SKILLS_DIR_PATH);
47088
48098
  const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
47089
48099
  rootDir: outputRoot,
47090
- filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
48100
+ filePaths: await findFilesByGlobs([`*/**/${skillsDirPath}`], {
48101
+ cwd: outputRoot,
47091
48102
  type: "dir",
47092
48103
  followSymbolicLinks: false,
47093
48104
  ignore: [
47094
- `${root}/**/.*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`,
47095
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
47096
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
48105
+ `**/.*/**/${skillsDirPath}`,
48106
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
48107
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
47097
48108
  ]
47098
48109
  })
47099
48110
  }).toSorted();
47100
48111
  const roots = [];
47101
- const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
48112
+ const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH, await resolvedRelativePath({
48113
+ rootPath: outputRoot,
48114
+ targetPath: (0, node_path.join)(outputRoot, CLAUDECODE_SKILLS_DIR_PATH)
48115
+ })]);
47102
48116
  for (const dirPath of filteredDirPaths) {
47103
48117
  const scannedDirPath = (0, node_path.resolve)(dirPath);
47104
48118
  const check = await checkNestedSkillsRoot({
@@ -47106,7 +48120,7 @@ var ClaudecodeSkill = class extends ToolSkill {
47106
48120
  dirPath: scannedDirPath
47107
48121
  });
47108
48122
  if ("reason" in check) {
47109
- logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
48123
+ logger?.warn(`Skipping the nested Claude Code skills directory ${quoteForLog(scannedDirPath)}: ${check.reason} Its skills are not imported.`);
47110
48124
  continue;
47111
48125
  }
47112
48126
  if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
@@ -47693,11 +48707,16 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
47693
48707
  rootFrontmatter: rulesyncFrontmatter,
47694
48708
  section: copilotSection
47695
48709
  });
47696
- const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotFields } = copilotSection ?? {};
48710
+ const license = resolveLicense({
48711
+ rootFrontmatter: rulesyncFrontmatter,
48712
+ section: copilotSection
48713
+ });
48714
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, license: _license, ...copilotFields } = copilotSection ?? {};
47697
48715
  const copilotFrontmatter = {
47698
48716
  ...copilotFields,
47699
48717
  name: rulesyncFrontmatter.name,
47700
48718
  description: rulesyncFrontmatter.description,
48719
+ ...license !== void 0 && { license },
47701
48720
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
47702
48721
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
47703
48722
  };
@@ -47848,11 +48867,16 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
47848
48867
  rootFrontmatter: rulesyncFrontmatter,
47849
48868
  section: copilotcliSection
47850
48869
  });
47851
- const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotcliFields } = copilotcliSection ?? {};
48870
+ const license = resolveLicense({
48871
+ rootFrontmatter: rulesyncFrontmatter,
48872
+ section: copilotcliSection
48873
+ });
48874
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, license: _license, ...copilotcliFields } = copilotcliSection ?? {};
47852
48875
  const copilotcliFrontmatter = {
47853
48876
  ...copilotcliFields,
47854
48877
  name: rulesyncFrontmatter.name,
47855
48878
  description: rulesyncFrontmatter.description,
48879
+ ...license !== void 0 && { license },
47856
48880
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
47857
48881
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
47858
48882
  };
@@ -48003,13 +49027,17 @@ var CursorSkill = class CursorSkill extends ToolSkill {
48003
49027
  rootFrontmatter: rulesyncFrontmatter,
48004
49028
  section: cursorSection
48005
49029
  });
49030
+ const metadata = resolveMetadata({
49031
+ rootFrontmatter: rulesyncFrontmatter,
49032
+ section: cursorSection
49033
+ });
48006
49034
  const cursorFrontmatter = {
48007
49035
  name: rulesyncFrontmatter.name,
48008
49036
  description: rulesyncFrontmatter.description,
48009
49037
  ...cursorSection?.paths !== void 0 && { paths: cursorSection.paths },
48010
49038
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
48011
49039
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
48012
- ...cursorSection?.metadata !== void 0 && { metadata: cursorSection.metadata }
49040
+ ...metadata !== void 0 && { metadata }
48013
49041
  };
48014
49042
  return new CursorSkill({
48015
49043
  outputRoot,
@@ -48150,13 +49178,25 @@ var DeepagentsSkill = class DeepagentsSkill extends ToolSkill {
48150
49178
  const deepagentsSection = rulesyncFrontmatter.deepagents;
48151
49179
  const allowedTools = deepagentsSection?.["allowed-tools"];
48152
49180
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
49181
+ const license = resolveLicense({
49182
+ rootFrontmatter: rulesyncFrontmatter,
49183
+ section: deepagentsSection
49184
+ });
49185
+ const compatibility = resolveCompatibility({
49186
+ rootFrontmatter: rulesyncFrontmatter,
49187
+ section: deepagentsSection
49188
+ });
49189
+ const metadata = resolveMetadata({
49190
+ rootFrontmatter: rulesyncFrontmatter,
49191
+ section: deepagentsSection
49192
+ });
48153
49193
  const deepagentsFrontmatter = {
48154
49194
  name: rulesyncFrontmatter.name,
48155
49195
  description: rulesyncFrontmatter.description,
48156
49196
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
48157
- ...deepagentsSection?.license !== void 0 && { license: deepagentsSection.license },
48158
- ...deepagentsSection?.compatibility !== void 0 && { compatibility: deepagentsSection.compatibility },
48159
- ...deepagentsSection?.metadata !== void 0 && { metadata: deepagentsSection.metadata }
49197
+ ...license !== void 0 && { license },
49198
+ ...compatibility !== void 0 && { compatibility },
49199
+ ...metadata !== void 0 && { metadata }
48160
49200
  };
48161
49201
  return new DeepagentsSkill({
48162
49202
  outputRoot,
@@ -48508,11 +49548,26 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
48508
49548
  rootFrontmatter: rulesyncFrontmatter,
48509
49549
  section: factorydroidSection
48510
49550
  });
49551
+ const license = resolveLicense({
49552
+ rootFrontmatter: rulesyncFrontmatter,
49553
+ section: factorydroidSection
49554
+ });
49555
+ const compatibility = resolveCompatibility({
49556
+ rootFrontmatter: rulesyncFrontmatter,
49557
+ section: factorydroidSection
49558
+ });
49559
+ const metadata = resolveMetadata({
49560
+ rootFrontmatter: rulesyncFrontmatter,
49561
+ section: factorydroidSection
49562
+ });
48511
49563
  const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
48512
49564
  const factorydroidFrontmatter = {
48513
49565
  name: rulesyncFrontmatter.name,
48514
49566
  description: rulesyncFrontmatter.description,
48515
49567
  ...section,
49568
+ ...license !== void 0 && { license },
49569
+ ...compatibility !== void 0 && { compatibility },
49570
+ ...metadata !== void 0 && { metadata },
48516
49571
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
48517
49572
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
48518
49573
  };
@@ -49104,7 +50159,7 @@ const KiloSkillFrontmatterSchema = zod_mini.z.looseObject({
49104
50159
  name: zod_mini.z.string(),
49105
50160
  description: zod_mini.z.string(),
49106
50161
  license: zod_mini.z.optional(zod_mini.z.string()),
49107
- compatibility: zod_mini.z.optional(zod_mini.z.looseObject({})),
50162
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
49108
50163
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
49109
50164
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
49110
50165
  });
@@ -49179,13 +50234,25 @@ var KiloSkill = class KiloSkill extends ToolSkill {
49179
50234
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49180
50235
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49181
50236
  const kiloSection = rulesyncFrontmatter.kilo;
50237
+ const license = resolveLicense({
50238
+ rootFrontmatter: rulesyncFrontmatter,
50239
+ section: kiloSection
50240
+ });
50241
+ const compatibility = resolveCompatibility({
50242
+ rootFrontmatter: rulesyncFrontmatter,
50243
+ section: kiloSection
50244
+ });
50245
+ const metadata = resolveMetadata({
50246
+ rootFrontmatter: rulesyncFrontmatter,
50247
+ section: kiloSection
50248
+ });
49182
50249
  const kiloFrontmatter = {
49183
50250
  name: rulesyncFrontmatter.name,
49184
50251
  description: rulesyncFrontmatter.description,
49185
50252
  ...kiloSection?.["allowed-tools"] !== void 0 && { "allowed-tools": kiloSection["allowed-tools"] },
49186
- ...kiloSection?.license !== void 0 && { license: kiloSection.license },
49187
- ...kiloSection?.compatibility !== void 0 && { compatibility: kiloSection.compatibility },
49188
- ...kiloSection?.metadata !== void 0 && { metadata: kiloSection.metadata }
50253
+ ...license !== void 0 && { license },
50254
+ ...compatibility !== void 0 && { compatibility },
50255
+ ...metadata !== void 0 && { metadata }
49189
50256
  };
49190
50257
  const settablePaths = KiloSkill.getSettablePaths({ global });
49191
50258
  return new KiloSkill({
@@ -49517,10 +50584,26 @@ var KiroSkill = class KiroSkill extends ToolSkill {
49517
50584
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49518
50585
  const settablePaths = KiroSkill.getSettablePaths({ global });
49519
50586
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
50587
+ const kiroSection = rulesyncFrontmatter.kiro;
50588
+ const license = resolveLicense({
50589
+ rootFrontmatter: rulesyncFrontmatter,
50590
+ section: kiroSection
50591
+ });
50592
+ const compatibility = resolveCompatibility({
50593
+ rootFrontmatter: rulesyncFrontmatter,
50594
+ section: kiroSection
50595
+ });
50596
+ const metadata = resolveMetadata({
50597
+ rootFrontmatter: rulesyncFrontmatter,
50598
+ section: kiroSection
50599
+ });
49520
50600
  const kiroFrontmatter = {
49521
- ...rulesyncFrontmatter.kiro,
50601
+ ...kiroSection,
49522
50602
  name: rulesyncFrontmatter.name,
49523
- description: rulesyncFrontmatter.description
50603
+ description: rulesyncFrontmatter.description,
50604
+ ...license !== void 0 && { license },
50605
+ ...compatibility !== void 0 && { compatibility },
50606
+ ...metadata !== void 0 && { metadata }
49524
50607
  };
49525
50608
  return new KiroSkill({
49526
50609
  outputRoot,
@@ -49754,15 +50837,6 @@ const OpenCodeSkillFrontmatterSchema = zod_mini.z.looseObject({
49754
50837
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
49755
50838
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
49756
50839
  });
49757
- /**
49758
- * Reads a top-level `compatibility` value from rulesync frontmatter, accepting
49759
- * both the documented string form (e.g. `compatibility: opencode`) and the
49760
- * legacy object form. Returns `undefined` for any other shape.
49761
- */
49762
- function readTopLevelCompatibility(value) {
49763
- if (typeof value === "string") return value;
49764
- if (typeof value === "object" && value !== null) return value;
49765
- }
49766
50840
  var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49767
50841
  constructor({ outputRoot = process.cwd(), relativeDirPath = OPENCODE_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
49768
50842
  super({
@@ -49798,9 +50872,16 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49798
50872
  * configured path is read but never generated into. `skills.urls` is a
49799
50873
  * remote-fetch surface and is out of scope for a file-based generator.
49800
50874
  *
49801
- * Absolute paths and paths escaping the output root are dropped — an import
49802
- * root is joined onto `outputRoot`, and reaching outside it is not something
49803
- * a project config should be able to ask for.
50875
+ * Absolute paths and paths escaping the config directory are dropped — an
50876
+ * import root is joined onto that directory, and reaching outside it is not
50877
+ * something a project config should be able to ask for. A path is judged by
50878
+ * where it resolves, not by how it is spelled: a relative name that is a
50879
+ * symbolic link pointing out of the directory is an escape all the same and
50880
+ * is dropped too. A path that does not exist has nothing to resolve and is
50881
+ * compared as spelled against the resolved config directory, so it survives
50882
+ * only when that directory resolves to its own spelling and is dropped when
50883
+ * the directory is itself reached through a link. Either way it yields no
50884
+ * skills — the scan finds no directory under it.
49804
50885
  *
49805
50886
  * @see https://opencode.ai/config.json
49806
50887
  */
@@ -49814,7 +50895,12 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49814
50895
  outputRoot,
49815
50896
  global
49816
50897
  });
49817
- return skills.paths.filter((candidate) => typeof candidate === "string" && candidate !== "" && !(0, node_path.isAbsolute)(candidate) && !(0, node_path.normalize)(candidate).startsWith("..")).map((relativeDirPath) => ({
50898
+ const lexicallyContained = skills.paths.filter((candidate) => typeof candidate === "string" && candidate !== "" && !(0, node_path.isAbsolute)(candidate) && !(0, node_path.normalize)(candidate).startsWith(".."));
50899
+ const escapes = await Promise.all(lexicallyContained.map((candidate) => resolvedPathEscapesRoot({
50900
+ rootPath: configDir,
50901
+ targetPath: (0, node_path.join)(configDir, candidate)
50902
+ })));
50903
+ return lexicallyContained.filter((_, index) => !escapes[index]).map((relativeDirPath) => ({
49818
50904
  outputRoot: configDir,
49819
50905
  relativeDirPath
49820
50906
  }));
@@ -49868,13 +50954,18 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49868
50954
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49869
50955
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49870
50956
  const opencodeSection = rulesyncFrontmatter.opencode;
49871
- const looseTopLevel = rulesyncFrontmatter;
49872
- const topLevelLicense = typeof looseTopLevel.license === "string" ? looseTopLevel.license : void 0;
49873
- const topLevelCompatibility = readTopLevelCompatibility(looseTopLevel.compatibility);
49874
- const topLevelMetadata = typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null ? looseTopLevel.metadata : void 0;
49875
- const license = opencodeSection?.license ?? topLevelLicense;
49876
- const compatibility = opencodeSection?.compatibility ?? topLevelCompatibility;
49877
- const metadata = opencodeSection?.metadata ?? topLevelMetadata;
50957
+ const license = resolveLicense({
50958
+ rootFrontmatter: rulesyncFrontmatter,
50959
+ section: opencodeSection
50960
+ });
50961
+ const compatibility = resolveCompatibility({
50962
+ rootFrontmatter: rulesyncFrontmatter,
50963
+ section: opencodeSection
50964
+ });
50965
+ const metadata = resolveMetadata({
50966
+ rootFrontmatter: rulesyncFrontmatter,
50967
+ section: opencodeSection
50968
+ });
49878
50969
  const opencodeFrontmatter = {
49879
50970
  name: rulesyncFrontmatter.name,
49880
50971
  description: rulesyncFrontmatter.description,
@@ -50043,11 +51134,26 @@ var PiSkill = class PiSkill extends ToolSkill {
50043
51134
  });
50044
51135
  const { "allowed-tools": allowedTools, ...piSectionRest } = piSection ?? {};
50045
51136
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
51137
+ const license = resolveLicense({
51138
+ rootFrontmatter: rulesyncFrontmatter,
51139
+ section: piSection
51140
+ });
51141
+ const compatibility = resolveCompatibility({
51142
+ rootFrontmatter: rulesyncFrontmatter,
51143
+ section: piSection
51144
+ });
51145
+ const metadata = resolveMetadata({
51146
+ rootFrontmatter: rulesyncFrontmatter,
51147
+ section: piSection
51148
+ });
50046
51149
  const piFrontmatter = {
50047
51150
  name: rulesyncFrontmatter.name,
50048
51151
  description: rulesyncFrontmatter.description,
50049
51152
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
50050
51153
  ...piSectionRest,
51154
+ ...license !== void 0 && { license },
51155
+ ...compatibility !== void 0 && { compatibility },
51156
+ ...metadata !== void 0 && { metadata },
50051
51157
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
50052
51158
  };
50053
51159
  return new PiSkill({
@@ -50529,11 +51635,26 @@ var ReplitSkill = class ReplitSkill extends ToolSkill {
50529
51635
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
50530
51636
  const { "allowed-tools": allowedTools, ...replitSection } = rulesyncFrontmatter.replit ?? {};
50531
51637
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
51638
+ const license = resolveLicense({
51639
+ rootFrontmatter: rulesyncFrontmatter,
51640
+ section: replitSection
51641
+ });
51642
+ const compatibility = resolveCompatibility({
51643
+ rootFrontmatter: rulesyncFrontmatter,
51644
+ section: replitSection
51645
+ });
51646
+ const metadata = resolveMetadata({
51647
+ rootFrontmatter: rulesyncFrontmatter,
51648
+ section: replitSection
51649
+ });
50532
51650
  const replitFrontmatter = {
50533
51651
  name: rulesyncFrontmatter.name,
50534
51652
  description: rulesyncFrontmatter.description,
50535
51653
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
50536
- ...replitSection
51654
+ ...replitSection,
51655
+ ...license !== void 0 && { license },
51656
+ ...compatibility !== void 0 && { compatibility },
51657
+ ...metadata !== void 0 && { metadata }
50537
51658
  };
50538
51659
  return new ReplitSkill({
50539
51660
  outputRoot,
@@ -50886,30 +52007,24 @@ const VibeSkillFrontmatterSchema = zod_mini.z.looseObject({
50886
52007
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
50887
52008
  "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
50888
52009
  });
50889
- /** Resolve the top-level `license` field, if present as a string. */
50890
- function resolveTopLevelLicense(looseTopLevel) {
50891
- return typeof looseTopLevel.license === "string" ? looseTopLevel.license : void 0;
50892
- }
50893
- /** Resolve the top-level `compatibility` field (string or object), if present. */
50894
- function resolveTopLevelCompatibility(looseTopLevel) {
50895
- const value = looseTopLevel.compatibility;
50896
- if (typeof value === "string" || typeof value === "object" && value !== null) return value;
50897
- }
50898
- /** Resolve the top-level `metadata` field (object), if present. */
50899
- function resolveTopLevelMetadata(looseTopLevel) {
50900
- const value = looseTopLevel.metadata;
50901
- return typeof value === "object" && value !== null ? value : void 0;
50902
- }
50903
52010
  /**
50904
52011
  * Build the Vibe frontmatter from a rulesync skill frontmatter, preferring the
50905
- * dedicated `vibe` section over any loosely-typed top-level fields.
52012
+ * dedicated `vibe` section over the shared root-level fields.
50906
52013
  */
50907
52014
  function buildVibeFrontmatter(rulesyncFrontmatter) {
50908
52015
  const vibeSection = rulesyncFrontmatter.vibe;
50909
- const looseTopLevel = rulesyncFrontmatter;
50910
- const topLevelLicense = resolveTopLevelLicense(looseTopLevel);
50911
- const topLevelCompatibility = resolveTopLevelCompatibility(looseTopLevel);
50912
- const topLevelMetadata = resolveTopLevelMetadata(looseTopLevel);
52016
+ const license = resolveLicense({
52017
+ rootFrontmatter: rulesyncFrontmatter,
52018
+ section: vibeSection
52019
+ });
52020
+ const compatibility = resolveCompatibility({
52021
+ rootFrontmatter: rulesyncFrontmatter,
52022
+ section: vibeSection
52023
+ });
52024
+ const metadata = resolveMetadata({
52025
+ rootFrontmatter: rulesyncFrontmatter,
52026
+ section: vibeSection
52027
+ });
50913
52028
  const resolvedUserInvocable = resolveUserInvocable({
50914
52029
  rootFrontmatter: rulesyncFrontmatter,
50915
52030
  section: vibeSection
@@ -50917,9 +52032,9 @@ function buildVibeFrontmatter(rulesyncFrontmatter) {
50917
52032
  return {
50918
52033
  name: rulesyncFrontmatter.name,
50919
52034
  description: rulesyncFrontmatter.description,
50920
- ...vibeSection?.license !== void 0 || topLevelLicense !== void 0 ? { license: vibeSection?.license ?? topLevelLicense } : {},
50921
- ...vibeSection?.compatibility !== void 0 || topLevelCompatibility !== void 0 ? { compatibility: vibeSection?.compatibility ?? topLevelCompatibility } : {},
50922
- ...vibeSection?.metadata !== void 0 || topLevelMetadata !== void 0 ? { metadata: vibeSection?.metadata ?? topLevelMetadata } : {},
52035
+ ...license !== void 0 && { license },
52036
+ ...compatibility !== void 0 && { compatibility },
52037
+ ...metadata !== void 0 && { metadata },
50923
52038
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
50924
52039
  ...vibeSection?.["allowed-tools"] !== void 0 && { "allowed-tools": vibeSection["allowed-tools"] }
50925
52040
  };
@@ -51899,7 +53014,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
51899
53014
  global: this.global
51900
53015
  });
51901
53016
  if (dirWriteBlockReason !== void 0 && dirWriteBlockReason !== null) {
51902
- this.logger.warn(`Skipping skill ${JSON.stringify(stripControlCharacters(dirName))} for '${this.toolTarget}': ${dirWriteBlockReason}`);
53017
+ this.logger.warn(`Skipping skill ${quoteForLog(dirName)} for '${this.toolTarget}': ${dirWriteBlockReason}`);
51903
53018
  return null;
51904
53019
  }
51905
53020
  return factory.class.fromRulesyncSkill({
@@ -52055,7 +53170,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52055
53170
  };
52056
53171
  } catch (error) {
52057
53172
  if (!isLenientRoot) throw error;
52058
- this.logger.warn(`Skipping ${JSON.stringify(stripControlCharacters(sourcePath))}: ` + stripControlCharacters(formatError(error)));
53173
+ this.logger.warn(`Skipping ${quoteForLog(sourcePath)}: ` + stripControlCharacters(formatError(error)));
52059
53174
  return null;
52060
53175
  }
52061
53176
  }))).filter((loaded) => loaded !== null);
@@ -52086,7 +53201,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52086
53201
  };
52087
53202
  } catch (error) {
52088
53203
  if (!isLenientRoot) throw error;
52089
- this.logger.warn(`Skipping ${JSON.stringify(stripControlCharacters(sourcePath))}: ` + stripControlCharacters(formatError(error)));
53204
+ this.logger.warn(`Skipping ${quoteForLog(sourcePath)}: ` + stripControlCharacters(formatError(error)));
52090
53205
  return null;
52091
53206
  }
52092
53207
  }))).filter((loaded) => loaded !== null);
@@ -52114,7 +53229,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52114
53229
  return names.filter((name) => {
52115
53230
  if (isAddressableSkillName(kind === "file" ? (0, node_path.basename)(name, ".md") : name)) return true;
52116
53231
  const consequence = kind === "file" ? "skill name cannot contain a path separator, so this file is neither imported nor swept as an orphan. Rename it by hand." : "skill directory name cannot contain a path separator, so this directory is neither generated from nor swept as an orphan. Rename or remove it by hand.";
52117
- warnOnceWithFallback(this.logger, `Skipping ${JSON.stringify(stripControlCharacters((0, node_path.join)(dirPath, name)))}: a ${consequence}`);
53232
+ warnOnceWithFallback(this.logger, `Skipping ${quoteForLog((0, node_path.join)(dirPath, name))}: a ${consequence}`);
52118
53233
  return false;
52119
53234
  });
52120
53235
  }
@@ -57050,7 +58165,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57050
58165
  supportsProject: true,
57051
58166
  supportsSimulated: false,
57052
58167
  supportsGlobal: true,
57053
- filePattern: (0, node_path.join)("*", "AGENTS.md")
58168
+ filePattern: "*/AGENTS.md"
57054
58169
  }
57055
58170
  }],
57056
58171
  ["devin", {
@@ -57059,7 +58174,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57059
58174
  supportsProject: true,
57060
58175
  supportsSimulated: false,
57061
58176
  supportsGlobal: true,
57062
- filePattern: (0, node_path.join)("*", "AGENT.md")
58177
+ filePattern: "*/AGENT.md"
57063
58178
  }
57064
58179
  }],
57065
58180
  ["factorydroid", {
@@ -57149,7 +58264,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57149
58264
  supportsProject: true,
57150
58265
  supportsSimulated: false,
57151
58266
  supportsGlobal: true,
57152
- filePattern: (0, node_path.join)("**", "*.md")
58267
+ filePattern: "**/*.md"
57153
58268
  }
57154
58269
  }],
57155
58270
  ["opencode", {
@@ -57176,7 +58291,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57176
58291
  supportsProject: true,
57177
58292
  supportsSimulated: false,
57178
58293
  supportsGlobal: true,
57179
- filePattern: (0, node_path.join)("*", "SKILL.md")
58294
+ filePattern: "*/SKILL.md"
57180
58295
  }
57181
58296
  }],
57182
58297
  ["roo", {
@@ -57413,7 +58528,10 @@ var SubagentsProcessor = class extends FeatureProcessor {
57413
58528
  rootPath: rootOutputRoot,
57414
58529
  targetPath: baseDir
57415
58530
  });
57416
- const subagentFilePaths = await findFilesByGlobs((0, node_path.join)(baseDir, factory.meta.filePattern), { followSymbolicLinks: !forDeletion });
58531
+ const subagentFilePaths = await findFilesByGlobs(factory.meta.filePattern, {
58532
+ cwd: baseDir,
58533
+ followSymbolicLinks: !forDeletion
58534
+ });
57417
58535
  const toRelativeFilePath = (path) => (0, node_path.relative)(baseDir, path);
57418
58536
  let ownedFilePaths = subagentFilePaths;
57419
58537
  if (factory.class.isFileOwned) {
@@ -57636,15 +58754,14 @@ var ToolRule = class extends ToolFile {
57636
58754
  * than files under a rulesync-owned directory, so enumerating them for
57637
58755
  * `--delete` would sweep away work rulesync never wrote.
57638
58756
  */
57639
- static buildNestedFilePatterns({ outputRoot, fileName }) {
57640
- const root = toPosixPath(outputRoot);
58757
+ static buildNestedFilePatterns({ fileName }) {
57641
58758
  return {
57642
- include: [`${root}/**/${fileName}`],
58759
+ include: [`**/${fileName}`],
57643
58760
  ignore: [
57644
- `${root}/${fileName}`,
57645
- `${root}/**/.*/**`,
57646
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
57647
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
58761
+ fileName,
58762
+ "**/.*/**",
58763
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
58764
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
57648
58765
  ]
57649
58766
  };
57650
58767
  }
@@ -57805,11 +58922,8 @@ var AgentsMdRule = class AgentsMdRule extends ToolRule {
57805
58922
  *
57806
58923
  * @see https://agents.md/
57807
58924
  */
57808
- static getNestedFilePatterns({ outputRoot }) {
57809
- return this.buildNestedFilePatterns({
57810
- outputRoot,
57811
- fileName: AGENTSMD_RULE_FILE_NAME
57812
- });
58925
+ static getNestedFilePatterns() {
58926
+ return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
57813
58927
  }
57814
58928
  /**
57815
58929
  * The subproject directory this rule scopes, or `undefined` for the project
@@ -58758,6 +59872,78 @@ var AugmentcodeRule = class AugmentcodeRule extends ToolRule {
58758
59872
  }
58759
59873
  };
58760
59874
  //#endregion
59875
+ //#region src/features/rules/claudecode-language-settings.ts
59876
+ /**
59877
+ * Claude Code's native home for the root `language` key of `rulesync.jsonc`.
59878
+ *
59879
+ * Claude Code reads a top-level `language` setting, so for this target the
59880
+ * rules feature writes the preference there instead of appending a prompt
59881
+ * block to CLAUDE.md. The key is patched into the existing settings file
59882
+ * through the shared config gateway (the `rules` feature owns `language`
59883
+ * there and nothing else), so hooks, permissions, and deny lists written by
59884
+ * sibling features — and everything the user authored — are left alone.
59885
+ *
59886
+ * Removing `language` from `rulesync.jsonc` later does not retract the key
59887
+ * already written: the settings file is shared with the user's own
59888
+ * configuration, so nothing is ever deleted from it, and the value stays
59889
+ * until the user removes it by hand.
59890
+ */
59891
+ var ClaudecodeLanguageSettings = class ClaudecodeLanguageSettings extends ToolFile {
59892
+ /**
59893
+ * Project scope goes to `.claude/settings.local.json`: a response language
59894
+ * is a per-developer preference, and the local file is the one Claude Code
59895
+ * keeps out of version control. Global scope goes to `~/.claude/settings.json`
59896
+ * because Claude Code reads no `~/.claude/settings.local.json`.
59897
+ */
59898
+ static getSettablePaths({ global = false } = {}) {
59899
+ return {
59900
+ relativeDirPath: CLAUDECODE_DIR,
59901
+ relativeFilePath: global ? CLAUDECODE_SETTINGS_FILE_NAME : CLAUDECODE_SETTINGS_LOCAL_FILE_NAME
59902
+ };
59903
+ }
59904
+ static async fromLanguage({ outputRoot = process.cwd(), language, global = false, validate = true }) {
59905
+ const paths = this.getSettablePaths({ global });
59906
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
59907
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
59908
+ return new ClaudecodeLanguageSettings({
59909
+ outputRoot,
59910
+ relativeDirPath: paths.relativeDirPath,
59911
+ relativeFilePath: paths.relativeFilePath,
59912
+ fileContent: applySharedConfigPatch({
59913
+ fileKey: sharedConfigFileKey(paths),
59914
+ feature: "rules",
59915
+ existingContent,
59916
+ patch: { language: getClaudecodeLanguageValue(language) },
59917
+ filePath
59918
+ }),
59919
+ validate,
59920
+ global
59921
+ });
59922
+ }
59923
+ /**
59924
+ * A settings file the user (and other features) share: never swept as an
59925
+ * orphan, even though the rules feature stops writing it when `language`
59926
+ * is unset again.
59927
+ */
59928
+ isDeletable() {
59929
+ return false;
59930
+ }
59931
+ validate() {
59932
+ try {
59933
+ JSON.parse(this.fileContent);
59934
+ return {
59935
+ success: true,
59936
+ error: null
59937
+ };
59938
+ } catch (error) {
59939
+ return {
59940
+ success: false,
59941
+ error: new Error(`Invalid JSON in ${this.getRelativePathFromCwd()}: ${formatError(error)}`, { cause: error })
59942
+ };
59943
+ }
59944
+ }
59945
+ };
59946
+ //#endregion
58761
59947
  //#region src/features/rules/claudecode-legacy-rule.ts
58762
59948
  /**
58763
59949
  * Legacy rule generator for Claude Code AI assistant
@@ -58893,6 +60079,15 @@ var ClaudecodeRule = class ClaudecodeRule extends ToolRule {
58893
60079
  nonRoot: { relativeDirPath: buildToolPath(CLAUDECODE_DIR, CLAUDECODE_RULES_DIR_NAME, excludeToolDir) }
58894
60080
  };
58895
60081
  }
60082
+ /**
60083
+ * The settings file the rules feature patches `language` into (see
60084
+ * {@link ClaudecodeLanguageSettings}): not a rule path, so `getSettablePaths`
60085
+ * does not report it, but the shared-config gateway must know the rules
60086
+ * feature writes there.
60087
+ */
60088
+ static getExtraSharedWritePaths({ global = false } = {}) {
60089
+ return [ClaudecodeLanguageSettings.getSettablePaths({ global })];
60090
+ }
58896
60091
  constructor({ frontmatter, body, ...rest }) {
58897
60092
  if (rest.validate) {
58898
60093
  const result = ClaudecodeRuleFrontmatterSchema.safeParse(frontmatter);
@@ -59598,6 +60793,12 @@ const CursorRuleFrontmatterSchema = zod_mini.z.object({
59598
60793
  globs: zod_mini.z.optional(zod_mini.z.string()),
59599
60794
  alwaysApply: zod_mini.z.optional(zod_mini.z.boolean())
59600
60795
  });
60796
+ /**
60797
+ * Globs that match the whole project, and so say nothing `alwaysApply: true`
60798
+ * does not already say. Same pair the Cline and Qwen Code adapters treat as
60799
+ * universal.
60800
+ */
60801
+ const UNIVERSAL_GLOBS = /* @__PURE__ */ new Set(["**/*", "*"]);
59601
60802
  var CursorRule = class CursorRule extends ToolRule {
59602
60803
  frontmatter;
59603
60804
  body;
@@ -59626,7 +60827,10 @@ var CursorRule = class CursorRule extends ToolRule {
59626
60827
  const rawDescription = frontmatter.description;
59627
60828
  const description = typeof rawDescription === "string" ? rawDescription.replace(/\n+/g, " ").trim() : rawDescription;
59628
60829
  if (description) lines.push((0, js_yaml.dump)({ description }, { lineWidth: -1 }).trimEnd());
59629
- if (frontmatter.globs !== void 0) lines.push(`globs: ${frontmatter.globs}`);
60830
+ if (frontmatter.globs !== void 0) {
60831
+ const globs = String(frontmatter.globs).replace(/[\r\n\u0085\u2028\u2029]+/g, " ").trim();
60832
+ lines.push(`globs: ${globs}`);
60833
+ }
59630
60834
  lines.push("---");
59631
60835
  lines.push("");
59632
60836
  if (body) lines.push(body);
@@ -59644,11 +60848,12 @@ var CursorRule = class CursorRule extends ToolRule {
59644
60848
  toRulesyncRule() {
59645
60849
  const targets = ["*"];
59646
60850
  const isAlways = this.frontmatter.alwaysApply === true;
59647
- const hasGlobs = this.frontmatter.globs && this.frontmatter.globs.trim() !== "";
59648
- let globs;
59649
- if (hasGlobs && this.frontmatter.globs) globs = this.frontmatter.globs.split(",").map((g) => g.trim()).filter((g) => g.length > 0);
59650
- else if (isAlways) globs = ["**/*"];
59651
- else globs = [];
60851
+ const rawGlobs = this.frontmatter.globs;
60852
+ const sourceGlobs = rawGlobs && rawGlobs.trim() !== "" ? rawGlobs.split(",").map((g) => g.trim()).filter((g) => g.length > 0) : [];
60853
+ const globs = sourceGlobs.length === 0 && isAlways ? ["**/*"] : sourceGlobs;
60854
+ let cursorGlobs;
60855
+ if (sourceGlobs.length > 0) cursorGlobs = sourceGlobs;
60856
+ else if (isAlways) cursorGlobs = [];
59652
60857
  return new RulesyncRule({
59653
60858
  frontmatter: {
59654
60859
  targets,
@@ -59658,7 +60863,7 @@ var CursorRule = class CursorRule extends ToolRule {
59658
60863
  cursor: {
59659
60864
  alwaysApply: this.frontmatter.alwaysApply,
59660
60865
  description: this.frontmatter.description,
59661
- globs: globs.length > 0 ? globs : void 0
60866
+ globs: cursorGlobs
59662
60867
  }
59663
60868
  },
59664
60869
  body: this.body,
@@ -59668,21 +60873,44 @@ var CursorRule = class CursorRule extends ToolRule {
59668
60873
  });
59669
60874
  }
59670
60875
  /**
59671
- * Resolve cursor globs with priority: cursor-specific > parent
59672
- * Returns comma-separated string for Cursor format, or undefined if no globs
59673
- * @param cursorSpecificGlobs - Cursor-specific globs (takes priority if defined)
59674
- * @param parentGlobs - Parent globs (used if cursorSpecificGlobs is undefined)
60876
+ * Resolve the Cursor `globs` string with priority: cursor-specific > parent.
60877
+ *
60878
+ * The two ways `cursorSpecificGlobs` can be absent are deliberately different
60879
+ * and must stay that way: `undefined` means "no Cursor-specific opinion", so
60880
+ * the canonical globs are used, while an explicit `[]` means "this rule has
60881
+ * no Cursor globs" and wins over them. Collapsing the two -- say, to
60882
+ * `cursorSpecificGlobs?.length ? … : parentGlobs` -- would put a universal
60883
+ * canonical glob back onto every Always Apply rule.
60884
+ *
60885
+ * A universal glob (see {@link UNIVERSAL_GLOBS}) is dropped outright when the
60886
+ * rule is Always Apply. `alwaysApply: true` already applies the rule
60887
+ * everywhere, Cursor's docs say globs are ignored once it is set, and
60888
+ * Cursor's own staff describe the two together as a semantic conflict that
60889
+ * some versions resolve by classifying the rule as a glob rule instead of an
60890
+ * Always one. Dropping it here is what heals, on the next generate, both a
60891
+ * `.rulesync` file an older version wrote with the invented universal glob in
60892
+ * `cursor.globs` and a rule hand-written with a universal canonical glob
60893
+ * beside `cursor.alwaysApply: true`. Specific globs are left alone: they say
60894
+ * something a flag cannot, so they are the author's to keep even alongside
60895
+ * it.
59675
60896
  */
59676
- static resolveCursorGlobs(cursorSpecificGlobs, parentGlobs) {
60897
+ static resolveCursorGlobs({ cursorSpecificGlobs, parentGlobs, alwaysApply }) {
59677
60898
  const targetGlobs = cursorSpecificGlobs !== void 0 ? cursorSpecificGlobs : parentGlobs;
59678
- return targetGlobs && targetGlobs.length > 0 ? targetGlobs.join(",") : void 0;
60899
+ if (!targetGlobs || targetGlobs.length === 0) return;
60900
+ if (alwaysApply && targetGlobs.every((glob) => UNIVERSAL_GLOBS.has(glob.trim()))) return;
60901
+ return targetGlobs.join(",");
59679
60902
  }
59680
60903
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true }) {
59681
60904
  const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
60905
+ const alwaysApply = rulesyncFrontmatter.cursor?.alwaysApply;
59682
60906
  const cursorFrontmatter = {
59683
60907
  description: rulesyncFrontmatter.description,
59684
- globs: this.resolveCursorGlobs(rulesyncFrontmatter.cursor?.globs, rulesyncFrontmatter.globs),
59685
- alwaysApply: rulesyncFrontmatter.cursor?.alwaysApply ?? void 0
60908
+ globs: this.resolveCursorGlobs({
60909
+ cursorSpecificGlobs: rulesyncFrontmatter.cursor?.globs,
60910
+ parentGlobs: rulesyncFrontmatter.globs,
60911
+ alwaysApply: alwaysApply === true
60912
+ }),
60913
+ alwaysApply
59686
60914
  };
59687
60915
  const body = rulesyncRule.getBody();
59688
60916
  const newFileName = `${rulesyncRule.getRelativeFilePath().replace(/\.md$/, "")}.mdc`;
@@ -61007,15 +62235,14 @@ var KiroRule = class KiroRule extends ToolRule {
61007
62235
  * never wrote.
61008
62236
  * @see https://kiro.dev/docs/steering/
61009
62237
  */
61010
- static getNestedFilePatterns({ outputRoot }) {
61011
- const root = toPosixPath(outputRoot);
62238
+ static getNestedFilePatterns() {
61012
62239
  return {
61013
- include: [`${root}/**/${KIRO_NESTED_STEERING_FILE_NAME}`],
62240
+ include: [`**/${KIRO_NESTED_STEERING_FILE_NAME}`],
61014
62241
  ignore: [
61015
- `${root}/${KIRO_NESTED_STEERING_FILE_NAME}`,
61016
- `${root}/**/.*/**`,
61017
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
61018
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
62242
+ KIRO_NESTED_STEERING_FILE_NAME,
62243
+ "**/.*/**",
62244
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
62245
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
61019
62246
  ]
61020
62247
  };
61021
62248
  }
@@ -61645,8 +62872,8 @@ var QwencodeRule = class QwencodeRule extends ToolRule {
61645
62872
  * The personal local context file lives under `.qwen/`, not at the project
61646
62873
  * root where the settable root path points, so override the deletion glob.
61647
62874
  */
61648
- static getLocalRootFileGlob({ outputRoot, fileName }) {
61649
- return (0, node_path.join)(outputRoot, QWENCODE_DIR, fileName);
62875
+ static getLocalRootFileGlob({ fileName }) {
62876
+ return node_path.posix.join(toPosixPath(QWENCODE_DIR), fileName);
61650
62877
  }
61651
62878
  };
61652
62879
  //#endregion
@@ -61673,11 +62900,8 @@ var ReasonixRule = class ReasonixRule extends ToolRule {
61673
62900
  * (same exclusions, import-only, project scope).
61674
62901
  * @see https://github.com/esengine/DeepSeek-Reasonix/blob/v1.18.0/docs/SESSION_MEMORY_RETRIEVAL.md
61675
62902
  */
61676
- static getNestedFilePatterns({ outputRoot }) {
61677
- return this.buildNestedFilePatterns({
61678
- outputRoot,
61679
- fileName: REASONIX_RULE_FILE_NAME
61680
- });
62903
+ static getNestedFilePatterns() {
62904
+ return this.buildNestedFilePatterns({ fileName: REASONIX_RULE_FILE_NAME });
61681
62905
  }
61682
62906
  /**
61683
62907
  * The subproject directory this rule scopes, or `undefined` for the root
@@ -61934,9 +63158,9 @@ var RooRule = class RooRule extends ToolRule {
61934
63158
  * generic directory is the only one the deletion sweep enumerates, because a
61935
63159
  * `rules-*` glob would also match mode rules a user wrote by hand.
61936
63160
  */
61937
- static getNestedFilePatterns({ outputRoot }) {
63161
+ static getNestedFilePatterns() {
61938
63162
  return {
61939
- include: [`${toPosixPath(outputRoot)}/${toPosixPath(ROO_DIR)}/rules-*/**/*.md`],
63163
+ include: [`${toPosixPath(ROO_DIR)}/rules-*/**/*.md`],
61940
63164
  ignore: []
61941
63165
  };
61942
63166
  }
@@ -61974,8 +63198,8 @@ var RooRule = class RooRule extends ToolRule {
61974
63198
  * Glob for the `separate-local-file` deletion; Roo reads `AGENTS.local.md`
61975
63199
  * at the project root, not under `.roo/` (mirrors rovodev).
61976
63200
  */
61977
- static getLocalRootFileGlob({ outputRoot, fileName }) {
61978
- return (0, node_path.join)(outputRoot, fileName);
63201
+ static getLocalRootFileGlob({ fileName }) {
63202
+ return fileName;
61979
63203
  }
61980
63204
  };
61981
63205
  //#endregion
@@ -62155,15 +63379,15 @@ var RovodevRule = class RovodevRule extends ToolRule {
62155
63379
  root: true
62156
63380
  })];
62157
63381
  },
62158
- getMirrorDeletionGlobs: ({ outputRoot }) => ({
62159
- primaryGlob: (0, node_path.join)(outputRoot, ROVODEV_DIR, ROVODEV_RULE_FILE_NAME),
62160
- mirrorGlob: (0, node_path.join)(outputRoot, ROVODEV_RULE_FILE_NAME)
63382
+ getMirrorDeletionGlobs: () => ({
63383
+ primaryGlob: node_path.posix.join(toPosixPath(ROVODEV_DIR), ROVODEV_RULE_FILE_NAME),
63384
+ mirrorGlob: ROVODEV_RULE_FILE_NAME
62161
63385
  })
62162
63386
  };
62163
63387
  }
62164
63388
  /** Glob for the `separate-local-file` deletion; rovodev writes it at project root, not under `.rovodev/`. */
62165
- static getLocalRootFileGlob({ outputRoot, fileName }) {
62166
- return (0, node_path.join)(outputRoot, fileName);
63389
+ static getLocalRootFileGlob({ fileName }) {
63390
+ return fileName;
62167
63391
  }
62168
63392
  };
62169
63393
  //#endregion
@@ -62303,11 +63527,8 @@ var VibeRule = class VibeRule extends ToolRule {
62303
63527
  * literally the same files.
62304
63528
  * @see https://github.com/mistralai/mistral-vibe/blob/main/vibe/core/config/harness_files/_harness_manager.py
62305
63529
  */
62306
- static getNestedFilePatterns({ outputRoot }) {
62307
- return this.buildNestedFilePatterns({
62308
- outputRoot,
62309
- fileName: AGENTSMD_RULE_FILE_NAME
62310
- });
63530
+ static getNestedFilePatterns() {
63531
+ return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
62311
63532
  }
62312
63533
  /**
62313
63534
  * The subproject directory this rule scopes, or `undefined` for the root file
@@ -63058,21 +64279,34 @@ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
63058
64279
  * the tool will read" — can keep the two apart. A legacy root is a file
63059
64280
  * Rulesync reads but never writes, and the difference matters to them.
63060
64281
  */
63061
- const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
64282
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob, outputRoot) => {
63062
64283
  if (primaryFilePaths.length > 0) return primaryFilePaths;
63063
- if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
64284
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob), { cwd: outputRoot });
63064
64285
  return [];
63065
64286
  };
64287
+ /**
64288
+ * A project-root-relative glob for a file a tool keeps at a fixed path, joined
64289
+ * with `/` because a glob is always posix-separated.
64290
+ *
64291
+ * Relative because the root goes to `findFilesByGlobs` as `cwd` rather than into
64292
+ * the pattern: a project directory named `project(a)` or `project{a,b}` would
64293
+ * otherwise be read as a glob and match nothing at all — and on a `--delete`
64294
+ * sweep an empty result reads as "every source was removed", so rulesync would
64295
+ * delete generated files it can no longer regenerate and report success.
64296
+ */
64297
+ const rootRelativeGlob = (...segments) => node_path.posix.join(...segments.filter((segment) => segment !== void 0).map(toPosixPath));
63066
64298
  var RulesProcessor = class extends FeatureProcessor {
63067
64299
  toolTarget;
63068
64300
  simulateCommands;
63069
64301
  simulateSubagents;
63070
64302
  simulateSkills;
64303
+ language;
64304
+ deriveSubprojectPathFromGlobs;
63071
64305
  global;
63072
64306
  getFactory;
63073
64307
  skills;
63074
64308
  featureOptions;
63075
- constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
64309
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, language, deriveSubprojectPathFromGlobs = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
63076
64310
  super({
63077
64311
  outputRoot,
63078
64312
  inputRoots,
@@ -63086,6 +64320,8 @@ var RulesProcessor = class extends FeatureProcessor {
63086
64320
  this.simulateCommands = simulateCommands;
63087
64321
  this.simulateSubagents = simulateSubagents;
63088
64322
  this.simulateSkills = simulateSkills;
64323
+ this.language = language;
64324
+ this.deriveSubprojectPathFromGlobs = deriveSubprojectPathFromGlobs;
63089
64325
  this.getFactory = getFactory;
63090
64326
  this.skills = skills;
63091
64327
  this.featureOptions = featureOptions;
@@ -63129,8 +64365,10 @@ var RulesProcessor = class extends FeatureProcessor {
63129
64365
  });
63130
64366
  this.applyRootRuleSections({
63131
64367
  toolRules,
63132
- factory
64368
+ factory,
64369
+ convertedRules
63133
64370
  });
64371
+ extraFiles.push(...await this.buildLanguageSettingsFiles());
63134
64372
  const outputFiles = [...toolRules, ...extraFiles];
63135
64373
  this.warnForOutputPathCollisions({
63136
64374
  outputFiles,
@@ -63188,7 +64426,8 @@ var RulesProcessor = class extends FeatureProcessor {
63188
64426
  outputRoot: this.outputRoot,
63189
64427
  instructions: instructionPaths,
63190
64428
  validate: true,
63191
- global: this.global
64429
+ global: this.global,
64430
+ logger: this.logger
63192
64431
  });
63193
64432
  return registered ? [registered] : [];
63194
64433
  }
@@ -63197,11 +64436,19 @@ var RulesProcessor = class extends FeatureProcessor {
63197
64436
  * reference and conventions sections to the root rule content. Mutates the
63198
64437
  * root rule in place.
63199
64438
  */
63200
- applyRootRuleSections({ toolRules, factory }) {
64439
+ applyRootRuleSections({ toolRules, factory, convertedRules }) {
63201
64440
  const { meta } = factory;
63202
64441
  const rootRule = toolRules.find((rule) => rule.isRoot());
63203
- if (!rootRule) return;
63204
- const newContent = this.generateReferenceSectionFromMeta(meta, toolRules) + (!meta.createsSeparateConventionsRule && meta.additionalConventions ? this.generateAdditionalConventionsSectionFromMeta(meta) : "") + rootRule.getFileContent();
64442
+ if (!rootRule) {
64443
+ this.appendLanguageBlockToRootSourceRules({ convertedRules });
64444
+ return;
64445
+ }
64446
+ const assembledContent = this.generateReferenceSectionFromMeta(meta, toolRules) + (!meta.createsSeparateConventionsRule && meta.additionalConventions ? this.generateAdditionalConventionsSectionFromMeta(meta) : "") + rootRule.getFileContent();
64447
+ const promptLanguage = this.getPromptBlockLanguage();
64448
+ const newContent = promptLanguage === void 0 ? assembledContent : appendLanguageBlock({
64449
+ content: assembledContent,
64450
+ language: promptLanguage
64451
+ });
63205
64452
  rootRule.setFileContent(newContent);
63206
64453
  const rootMirror = factory.class.getRootMirror?.();
63207
64454
  if (rootMirror && !this.global) toolRules.push(...rootMirror.getMirrorFiles({
@@ -63210,6 +64457,53 @@ var RulesProcessor = class extends FeatureProcessor {
63210
64457
  content: newContent
63211
64458
  }));
63212
64459
  }
64460
+ /**
64461
+ * The language delivered as a prompt block, or `undefined` when none is:
64462
+ * `language` is unset, or the target is Claude Code, which has a native
64463
+ * `language` setting (see {@link ClaudecodeLanguageSettings}) and so gets
64464
+ * no block in its root file.
64465
+ */
64466
+ getPromptBlockLanguage() {
64467
+ return this.isClaudecodeTarget() ? void 0 : this.language;
64468
+ }
64469
+ isClaudecodeTarget() {
64470
+ return this.toolTarget === "claudecode" || this.toolTarget === "claudecode-legacy";
64471
+ }
64472
+ /**
64473
+ * The language block for targets whose adapters never mark a ToolRule as
64474
+ * root: Cursor emits every rule as `.cursor/rules/*.mdc`, and the fixed-name
64475
+ * targets (Cline, Roo, Kiro, ...) file the `root: true` source beside the
64476
+ * others. The file produced from the `root: true` source is still the root
64477
+ * rule from the user's point of view, so it is the one that gets the block.
64478
+ * Nested rules never do. When several sources are marked `root: true`, only
64479
+ * the file built from the first one (in conversion order, which follows the
64480
+ * source order) carries the block: one instruction per target is the
64481
+ * contract, and a root-marking target gets exactly one as well.
64482
+ */
64483
+ appendLanguageBlockToRootSourceRules({ convertedRules }) {
64484
+ const language = this.getPromptBlockLanguage();
64485
+ if (language === void 0) return;
64486
+ const firstRootSource = convertedRules.find(({ rulesyncRule }) => rulesyncRule.getFrontmatter().root === true);
64487
+ if (firstRootSource === void 0) return;
64488
+ const { toolRule } = firstRootSource;
64489
+ toolRule.setFileContent(appendLanguageBlock({
64490
+ content: toolRule.getFileContent(),
64491
+ language
64492
+ }));
64493
+ }
64494
+ /**
64495
+ * Claude Code's native delivery of `language`: a patch to the settings file
64496
+ * instead of a prompt block. Empty for every other target and when
64497
+ * `language` is unset, so an unset key never touches the settings file.
64498
+ */
64499
+ async buildLanguageSettingsFiles() {
64500
+ if (this.language === void 0 || !this.isClaudecodeTarget()) return [];
64501
+ return [await ClaudecodeLanguageSettings.fromLanguage({
64502
+ outputRoot: this.outputRoot,
64503
+ language: this.language,
64504
+ global: this.global
64505
+ })];
64506
+ }
63213
64507
  buildSkillList(skillClass) {
63214
64508
  if (!this.skills) return [];
63215
64509
  const toolRelativeDirPath = skillClass.getSettablePaths({ global: this.global }).relativeDirPath;
@@ -63439,7 +64733,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63439
64733
  const toolRules = toolFiles.filter((file) => file instanceof ToolRule);
63440
64734
  const rulesyncRules = toolRules.map((toolRule) => {
63441
64735
  if (toolRule.isLocalRoot()) return toolRule.toLocalRootRulesyncRule({ targets: [this.toolTarget] });
63442
- return toolRule.toRulesyncRule();
64736
+ return this.withoutLanguageBlock({
64737
+ toolRule,
64738
+ rulesyncRule: toolRule.toRulesyncRule()
64739
+ });
63443
64740
  });
63444
64741
  const claimedBy = /* @__PURE__ */ new Map();
63445
64742
  for (const [index, rulesyncRule] of rulesyncRules.entries()) {
@@ -63455,6 +64752,33 @@ As this project's AI coding tool, you must follow the additional conventions bel
63455
64752
  return rulesyncRules;
63456
64753
  }
63457
64754
  /**
64755
+ * Drop the language block a previous `generate` appended, so that importing
64756
+ * a generated root file and generating again yields one block, not two.
64757
+ * Every imported rule is checked, not just root ones: Cursor and the
64758
+ * fixed-name targets import their root file as a non-root rule. The
64759
+ * `language` key itself lives in `rulesync.jsonc`, so nothing about the
64760
+ * detected language is carried into the rulesync rule — which is why the
64761
+ * strip is reported: a user who imports a file carrying the block and has
64762
+ * not set `language` would otherwise lose the instruction without a trace.
64763
+ * Once per file per run, since an import over several targets reads the
64764
+ * same root file for each of them.
64765
+ */
64766
+ withoutLanguageBlock({ toolRule, rulesyncRule }) {
64767
+ const body = rulesyncRule.getBody();
64768
+ const stripped = stripLanguageBlock(body);
64769
+ if (stripped === body) return rulesyncRule;
64770
+ const source = stripControlCharacters((0, node_path.join)(toolRule.getRelativeDirPath(), toolRule.getRelativeFilePath()));
64771
+ warnOnceWithFallback(this.logger, `Removed the answer-language block rulesync appends from ${source} on import; it is not kept in .rulesync/rules/. Set "language" in rulesync.jsonc to keep generating it.`);
64772
+ return new RulesyncRule({
64773
+ outputRoot: rulesyncRule.getOutputRoot(),
64774
+ relativeDirPath: rulesyncRule.getRelativeDirPath(),
64775
+ relativeFilePath: rulesyncRule.getRelativeFilePath(),
64776
+ frontmatter: rulesyncRule.getFrontmatter(),
64777
+ body: stripped,
64778
+ validate: false
64779
+ });
64780
+ }
64781
+ /**
63458
64782
  * Load rulesync rule files from a single source-tree's `rules/` (and
63459
64783
  * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
63460
64784
  * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
@@ -63471,7 +64795,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
63471
64795
  const rulesyncOutputRoot = (0, node_path.join)(sourceTree, RULES_FEATURE_SUBDIR);
63472
64796
  const curatedOutputRoot = (0, node_path.join)(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
63473
64797
  const [rulesDirExists, curatedDirExists] = await Promise.all([directoryExistsStrict(rulesyncOutputRoot), directoryExistsStrict(curatedOutputRoot)]);
63474
- const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([rulesDirExists ? findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")) : [], curatedDirExists ? findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md")) : []]);
64798
+ const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([rulesDirExists ? findFilesByGlobs("**/*.md", { cwd: rulesyncOutputRoot }) : [], curatedDirExists ? findFilesByGlobs("**/*.md", { cwd: curatedOutputRoot }) : []]);
63475
64799
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
63476
64800
  const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
63477
64801
  const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
@@ -63508,7 +64832,8 @@ As this project's AI coding tool, you must follow the additional conventions bel
63508
64832
  const rule = await RulesyncRule.fromFile({
63509
64833
  outputRoot: treeParent,
63510
64834
  relativeDirPath: treeRulesDirPath,
63511
- relativeFilePath: sourceRelativeFilePath
64835
+ relativeFilePath: sourceRelativeFilePath,
64836
+ deriveSubprojectPathFromGlobs: this.deriveSubprojectPathFromGlobs
63512
64837
  });
63513
64838
  if (sourceRelativeFilePath === relativeFilePath) return rule;
63514
64839
  return new RulesyncRule({
@@ -63657,10 +64982,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63657
64982
  * Resolved once, up front, so the two blocks that need it do not depend
63658
64983
  * on each other's evaluation order.
63659
64984
  */
63660
- const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
64985
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(rootRelativeGlob(settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath), { cwd: this.outputRoot }) : [];
63661
64986
  const rootToolRules = await (async () => {
63662
64987
  if (!settablePaths.root) return [];
63663
- const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
64988
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => rootRelativeGlob(alt.relativeDirPath, alt.relativeFilePath), this.outputRoot);
63664
64989
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
63665
64990
  return await buildImportRulesFromPaths(uniqueRootFilePaths);
63666
64991
  })();
@@ -63669,12 +64994,9 @@ As this project's AI coding tool, you must follow the additional conventions bel
63669
64994
  if (this.global || factory.meta.localRootMode !== "separate-local-file" || !factory.meta.localRootFileName) return [];
63670
64995
  const fileName = factory.meta.localRootFileName;
63671
64996
  const filePaths = await (async () => {
63672
- if (factory.class.getLocalRootFileGlob) return await findFilesByGlobs(factory.class.getLocalRootFileGlob({
63673
- outputRoot: this.outputRoot,
63674
- fileName
63675
- }));
64997
+ if (factory.class.getLocalRootFileGlob) return await findFilesByGlobs(factory.class.getLocalRootFileGlob({ fileName }), { cwd: this.outputRoot });
63676
64998
  if (!settablePaths.root) return [];
63677
- return await findFilesWithFallback(await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
64999
+ return await findFilesWithFallback(await findFilesByGlobs(rootRelativeGlob(settablePaths.root.relativeDirPath ?? ".", fileName), { cwd: this.outputRoot }), settablePaths.alternativeRoots, (alt) => rootRelativeGlob(alt.relativeDirPath, fileName), this.outputRoot);
63678
65000
  })();
63679
65001
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
63680
65002
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -63697,15 +65019,15 @@ As this project's AI coding tool, you must follow the additional conventions bel
63697
65019
  const rootMirrorDeletionRules = await (async () => {
63698
65020
  const rootMirror = factory.class.getRootMirror?.();
63699
65021
  if (!forDeletion || this.global || !rootMirror) return [];
63700
- const { primaryGlob, mirrorGlob } = rootMirror.getMirrorDeletionGlobs({ outputRoot: this.outputRoot });
63701
- if ((await findFilesByGlobs(primaryGlob)).length === 0) return [];
63702
- const mirrorPaths = await findFilesByGlobs(mirrorGlob);
65022
+ const { primaryGlob, mirrorGlob } = rootMirror.getMirrorDeletionGlobs();
65023
+ if ((await findFilesByGlobs(primaryGlob, { cwd: this.outputRoot })).length === 0) return [];
65024
+ const mirrorPaths = await findFilesByGlobs(mirrorGlob, { cwd: this.outputRoot });
63703
65025
  return buildDeletionRulesFromPaths(mirrorPaths);
63704
65026
  })();
63705
65027
  const extraFixedToolRules = await (async () => {
63706
65028
  const extraFiles = factory.class.getExtraFixedFiles?.({ global: this.global });
63707
65029
  if (!extraFiles || extraFiles.length === 0) return [];
63708
- const filePaths = await findFilesByGlobs(extraFiles.map((file) => (0, node_path.join)(this.outputRoot, file.relativeDirPath, file.relativeFilePath)));
65030
+ const filePaths = await findFilesByGlobs(extraFiles.map((file) => rootRelativeGlob(file.relativeDirPath, file.relativeFilePath)), { cwd: this.outputRoot });
63709
65031
  if (filePaths.length === 0) return [];
63710
65032
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
63711
65033
  return await Promise.all(filePaths.map((filePath) => {
@@ -63724,9 +65046,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63724
65046
  })();
63725
65047
  this.logger.debug(`Found ${extraFixedToolRules.length} extra fixed tool rule files`);
63726
65048
  const nestedToolRules = await (async () => {
63727
- const patterns = this.global ? void 0 : factory.class.getNestedFilePatterns?.({ outputRoot: this.outputRoot });
65049
+ const patterns = this.global ? void 0 : factory.class.getNestedFilePatterns?.();
63728
65050
  if (forDeletion || !patterns || patterns.include.length === 0) return [];
63729
65051
  const matchedPaths = await findFilesByGlobs(patterns.include, {
65052
+ cwd: this.outputRoot,
63730
65053
  type: "file",
63731
65054
  followSymbolicLinks: false,
63732
65055
  ignore: patterns.ignore
@@ -63757,7 +65080,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63757
65080
  const scannedPaths = [];
63758
65081
  const skippedPaths = [];
63759
65082
  for (const importOnlyRoot of importOnlyRoots) {
63760
- const matchedPaths = await findFilesByGlobs((0, node_path.join)(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
65083
+ const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
65084
+ cwd: this.outputRoot,
65085
+ type: "file"
65086
+ });
63761
65087
  if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
63762
65088
  skippedPaths.push(...matchedPaths);
63763
65089
  continue;
@@ -63776,7 +65102,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
63776
65102
  const nonRootToolRules = await (async () => {
63777
65103
  if (!settablePaths.nonRoot) return [];
63778
65104
  const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
63779
- const nonRootFilePaths = await findFilesByGlobs((0, node_path.join)(nonRootOutputRoot, "**", `*.${factory.meta.extension}`));
65105
+ const nonRootFilePaths = await findFilesByGlobs(`**/*.${factory.meta.extension}`, { cwd: nonRootOutputRoot });
63780
65106
  if (forDeletion) return buildDeletionRulesFromPaths(nonRootFilePaths, {
63781
65107
  outputRootOverride: nonRootOutputRoot,
63782
65108
  relativeDirPathOverride: settablePaths.nonRoot.relativeDirPath
@@ -63919,7 +65245,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
63919
65245
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
63920
65246
  */
63921
65247
  async function convertFromTool(params) {
63922
- resetWarnedOnceMessages();
65248
+ resetRunWarningState();
63923
65249
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
63924
65250
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
63925
65251
  const ctx = params;
@@ -64008,7 +65334,8 @@ function buildRulesStrategy(ctx) {
64008
65334
  toolTarget,
64009
65335
  global,
64010
65336
  dryRun,
64011
- logger
65337
+ logger,
65338
+ language: config.getLanguage()
64012
65339
  }),
64013
65340
  loadSource: (p) => p.loadToolFiles(),
64014
65341
  toRulesync: (p, files) => p.convertToolFilesToRulesyncFiles(files),
@@ -64398,6 +65725,9 @@ function createOrphanSweepPlan() {
64398
65725
  const resolved = (0, node_path.resolve)(path);
64399
65726
  return generatedPaths.has(resolved) || isInsideGeneratedTree(resolved);
64400
65727
  },
65728
+ isGeneratedExactly({ path }) {
65729
+ return generatedPaths.has((0, node_path.resolve)(path));
65730
+ },
64401
65731
  rejectClaimed({ items, getPath }) {
64402
65732
  return items.filter((item) => !plan.isGenerated({ path: getPath(item) }));
64403
65733
  },
@@ -64631,13 +65961,18 @@ async function processDirFeatureGeneration(params) {
64631
65961
  getPath: (d) => d.getDirPath()
64632
65962
  }), toolDirs);
64633
65963
  const existingFlatFiles = await processor.loadToolFlatFilesToDelete();
64634
- return orphanDirCount + await processor.removeOrphanFlatFiles({
65964
+ const orphanFileCount = await processor.removeOrphanFlatFiles({
64635
65965
  existingFlatFiles: sweepPlan.rejectClaimed({
64636
65966
  items: existingFlatFiles,
64637
65967
  getPath: (d) => d.getFlatFilePath() ?? d.getDirPath()
64638
65968
  }),
64639
65969
  generatedDirs: toolDirs
64640
- }) > 0;
65970
+ });
65971
+ const orphanInDirCount = await processor.removeOrphanFilesInAiDirs({
65972
+ generatedDirs: toolDirs,
65973
+ isClaimed: (path) => sweepPlan.isGeneratedExactly({ path })
65974
+ });
65975
+ return orphanDirCount + orphanFileCount + orphanInDirCount > 0;
64641
65976
  } });
64642
65977
  return {
64643
65978
  count: totalCount,
@@ -64956,7 +66291,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
64956
66291
  async function generate(params) {
64957
66292
  const { config, logger } = params;
64958
66293
  resetRootShadowingWarnings({ logger });
64959
- resetWarnedOnceMessages();
66294
+ resetRunWarningState();
64960
66295
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
64961
66296
  toolTarget,
64962
66297
  outputRoot
@@ -65116,6 +66451,8 @@ async function generateRulesCore(params) {
65116
66451
  simulateCommands: config.getSimulateCommands(),
65117
66452
  simulateSubagents: config.getSimulateSubagents(),
65118
66453
  simulateSkills: config.getSimulateSkills(),
66454
+ language: config.getLanguage(),
66455
+ deriveSubprojectPathFromGlobs: config.getDeriveSubprojectPathFromGlobs(),
65119
66456
  skills,
65120
66457
  featureOptions: config.getFeatureOptions(toolTarget, "rules"),
65121
66458
  dryRun: config.isPreviewMode(),
@@ -65566,7 +66903,7 @@ function getToolOutputRoot({ config, tool }) {
65566
66903
  */
65567
66904
  async function importFromTool(params) {
65568
66905
  const { config, tool, logger } = params;
65569
- resetWarnedOnceMessages();
66906
+ resetRunWarningState();
65570
66907
  await assertPluginRootSafe({
65571
66908
  toolTarget: tool,
65572
66909
  outputRoot: getToolOutputRoot({
@@ -66020,6 +67357,12 @@ Object.defineProperty(exports, "DEPRECATED_FEATURE_REPLACEMENTS", {
66020
67357
  return DEPRECATED_FEATURE_REPLACEMENTS;
66021
67358
  }
66022
67359
  });
67360
+ Object.defineProperty(exports, "ELLIPSIS_WIDTH", {
67361
+ enumerable: true,
67362
+ get: function() {
67363
+ return ELLIPSIS_WIDTH;
67364
+ }
67365
+ });
66023
67366
  Object.defineProperty(exports, "ErrorCodes", {
66024
67367
  enumerable: true,
66025
67368
  get: function() {
@@ -66392,6 +67735,12 @@ Object.defineProperty(exports, "__toESM", {
66392
67735
  return __toESM;
66393
67736
  }
66394
67737
  });
67738
+ Object.defineProperty(exports, "applyFileMode", {
67739
+ enumerable: true,
67740
+ get: function() {
67741
+ return applyFileMode;
67742
+ }
67743
+ });
66395
67744
  Object.defineProperty(exports, "assertDirectoryIfExists", {
66396
67745
  enumerable: true,
66397
67746
  get: function() {
@@ -66605,7 +67954,7 @@ Object.defineProperty(exports, "parseCommaSeparatedList", {
66605
67954
  Object.defineProperty(exports, "parseJsonc", {
66606
67955
  enumerable: true,
66607
67956
  get: function() {
66608
- return parseJsonc$8;
67957
+ return parseJsonc$6;
66609
67958
  }
66610
67959
  });
66611
67960
  Object.defineProperty(exports, "pathEscapesRoot", {
@@ -66614,6 +67963,12 @@ Object.defineProperty(exports, "pathEscapesRoot", {
66614
67963
  return pathEscapesRoot;
66615
67964
  }
66616
67965
  });
67966
+ Object.defineProperty(exports, "quoteForLog", {
67967
+ enumerable: true,
67968
+ get: function() {
67969
+ return quoteForLog;
67970
+ }
67971
+ });
66617
67972
  Object.defineProperty(exports, "readFileContent", {
66618
67973
  enumerable: true,
66619
67974
  get: function() {
@@ -66656,10 +68011,10 @@ Object.defineProperty(exports, "removeTempDirectory", {
66656
68011
  return removeTempDirectory;
66657
68012
  }
66658
68013
  });
66659
- Object.defineProperty(exports, "resetWarnedOnceMessages", {
68014
+ Object.defineProperty(exports, "resetRunWarningState", {
66660
68015
  enumerable: true,
66661
68016
  get: function() {
66662
- return resetWarnedOnceMessages;
68017
+ return resetRunWarningState;
66663
68018
  }
66664
68019
  });
66665
68020
  Object.defineProperty(exports, "resolveEffectiveInputRoots", {