rulesync 16.15.0 → 16.17.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.
@@ -34,6 +34,7 @@ let gray_matter = require("gray-matter");
34
34
  gray_matter = __toESM(gray_matter, 1);
35
35
  let js_yaml = require("js-yaml");
36
36
  let es_toolkit_object = require("es-toolkit/object");
37
+ let node_fs = require("node:fs");
37
38
  let node_crypto = require("node:crypto");
38
39
  let node_util = require("node:util");
39
40
  let smol_toml = require("smol-toml");
@@ -134,27 +135,34 @@ const isFeatureValueEnabled = (value) => {
134
135
  };
135
136
  //#endregion
136
137
  //#region src/constants/rulesync-paths.ts
137
- const { join: join$290 } = node_path.posix;
138
+ const { join: join$291 } = node_path.posix;
138
139
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
139
140
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
140
141
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
141
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "rules");
142
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$290(RULESYNC_RULES_RELATIVE_DIR_PATH, ".curated");
143
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "commands");
144
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "subagents");
145
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "checks");
146
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
147
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
148
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
149
- join$290(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
150
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
151
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
142
+ const RULES_FEATURE_SUBDIR = "rules";
143
+ const CURATED_RULES_FEATURE_SUBDIR = join$291(RULES_FEATURE_SUBDIR, ".curated");
144
+ const COMMANDS_FEATURE_SUBDIR = "commands";
145
+ const SUBAGENTS_FEATURE_SUBDIR = "subagents";
146
+ const CHECKS_FEATURE_SUBDIR = "checks";
147
+ const SKILLS_FEATURE_SUBDIR = "skills";
148
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$291(SKILLS_FEATURE_SUBDIR, ".curated");
149
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
150
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
151
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
152
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
153
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
154
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
155
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
156
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
157
+ join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
158
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
159
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
152
160
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
153
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
161
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
154
162
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
155
163
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
156
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "skills");
157
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$290(RULESYNC_SKILLS_RELATIVE_DIR_PATH, ".curated");
164
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
165
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
158
166
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
159
167
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
160
168
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -488,9 +496,22 @@ function isEnvTest() {
488
496
  }
489
497
  //#endregion
490
498
  //#region src/utils/file.ts
499
+ /**
500
+ * Whether a relative path leads out of the root it is relative to. Matching
501
+ * whole segments matters: a directory really named `..cache` relatively
502
+ * resolves to `..cache/file`, which a prefix test would report as an escape.
503
+ */
491
504
  function pathEscapesRoot(relativePath) {
492
505
  return relativePath === ".." || relativePath.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(relativePath);
493
506
  }
507
+ /** Whether a single path segment is a hidden (dot-prefixed) name. */
508
+ function isHiddenPathSegment(segment) {
509
+ return segment.startsWith(".") && segment !== "." && segment !== "..";
510
+ }
511
+ /** Split a path on both separators, so one predicate serves either platform. */
512
+ function splitPathSegments(filePath) {
513
+ return filePath.split(/[/\\]/);
514
+ }
494
515
  async function assertWritablePathInsideRoot(params) {
495
516
  const { rootPath, targetPath } = params;
496
517
  let existingPath = targetPath;
@@ -721,8 +742,45 @@ async function listDirectoryFiles(dir) {
721
742
  return [];
722
743
  }
723
744
  }
745
+ /** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
746
+ function countHiddenSegments(filePath) {
747
+ return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
748
+ }
749
+ /**
750
+ * The real file a path denotes, posix-separated so it compares against the globby results
751
+ * that produce it. Two paths share an identity when they resolve to the very same file --
752
+ * a link beside its target, a link into a shared tree, or a cycle that walks back into an
753
+ * ancestor and yields the same file forty levels down.
754
+ */
755
+ async function realFileIdentity(filePath) {
756
+ try {
757
+ return toPosixPath(await (0, node_fs_promises.realpath)(filePath));
758
+ } catch {
759
+ return toPosixPath(filePath);
760
+ }
761
+ }
762
+ /**
763
+ * Pick the one path that represents a file among the paths that resolve to it.
764
+ *
765
+ * The path that walked through no link at all wins outright: it is already the real one,
766
+ * so it equals the file's identity. That keeps the real location of a file as the path
767
+ * callers see, rather than an alias that happens to sort first -- a directory link named
768
+ * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
769
+ * `sub/note.md` with the same file reached back through the cycle.
770
+ * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
771
+ * named one represents the entry rather than a hidden alias that a hidden-entry rule may
772
+ * then drop, taking the named path's content with it. `candidates` arrives in sorted
773
+ * order, so ties keep the first one deterministically.
774
+ */
775
+ function chooseRepresentative(candidates, identity) {
776
+ return candidates.reduce((best, candidate) => {
777
+ if (toPosixPath(best) === identity) return best;
778
+ if (toPosixPath(candidate) === identity) return candidate;
779
+ return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
780
+ });
781
+ }
724
782
  async function findFilesByGlobs(globs, options = {}) {
725
- const { type = "all", followSymbolicLinks = true, ignore } = options;
783
+ const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
726
784
  const globbyOptions = type === "file" ? {
727
785
  onlyFiles: true,
728
786
  onlyDirectories: false
@@ -737,23 +795,18 @@ async function findFilesByGlobs(globs, options = {}) {
737
795
  const results = (0, globby.globbySync)(normalizedGlobs, {
738
796
  absolute: true,
739
797
  followSymbolicLinks,
798
+ dot,
740
799
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
741
800
  ...globbyOptions
742
801
  });
743
- const seenRealPaths = /* @__PURE__ */ new Set();
744
- const deduped = [];
802
+ const candidatesByFile = /* @__PURE__ */ new Map();
745
803
  for (const result of results.toSorted()) {
746
- let realResult;
747
- try {
748
- realResult = await (0, node_fs_promises.realpath)(result);
749
- } catch {
750
- realResult = result;
751
- }
752
- if (seenRealPaths.has(realResult)) continue;
753
- seenRealPaths.add(realResult);
754
- deduped.push(result);
804
+ const identity = await realFileIdentity(result);
805
+ const candidates = candidatesByFile.get(identity);
806
+ if (candidates === void 0) candidatesByFile.set(identity, [result]);
807
+ else candidates.push(result);
755
808
  }
756
- return deduped;
809
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
757
810
  }
758
811
  async function removeDirectory(dirPath) {
759
812
  if ([
@@ -912,6 +965,25 @@ var CLIError = class extends Error {
912
965
  }
913
966
  };
914
967
  //#endregion
968
+ //#region src/utils/warned-once.ts
969
+ /**
970
+ * The messages a once-per-run warning has already emitted in this process.
971
+ * This lives in its own module, free of imports, so the vitest setup file can
972
+ * clear it between tests without pulling `logger.js` into every test's module
973
+ * graph (which would defeat the module mocks some of those tests install).
974
+ */
975
+ const warnedOnceMessages = /* @__PURE__ */ new Set();
976
+ /** Whether `message` has not been emitted yet; records it when it has not. */
977
+ function claimWarnOnce(message) {
978
+ if (warnedOnceMessages.has(message)) return false;
979
+ warnedOnceMessages.add(message);
980
+ return true;
981
+ }
982
+ /** Forget which warnings were already emitted, so each test starts silent. */
983
+ function resetWarnedOnceMessages() {
984
+ warnedOnceMessages.clear();
985
+ }
986
+ //#endregion
915
987
  //#region src/utils/logger.ts
916
988
  /**
917
989
  * Base class for shared verbose/silent state and configuration logic
@@ -1064,6 +1136,17 @@ const fallbackLogger = new ConsoleLogger();
1064
1136
  function warnWithFallback(logger, message) {
1065
1137
  (logger ?? fallbackLogger).warn(message);
1066
1138
  }
1139
+ /**
1140
+ * Emit a warning at most once per run. A single `generate` reads the same source
1141
+ * file once per enabled tool target, so a warning that describes the source
1142
+ * rather than the target would otherwise be printed a dozen identical times.
1143
+ * Diagnostics that name the file they are about qualify; anything whose text
1144
+ * varies with what the user should do next does not.
1145
+ */
1146
+ function warnOnceWithFallback(logger, message) {
1147
+ if (!claimWarnOnce(message)) return;
1148
+ warnWithFallback(logger, message);
1149
+ }
1067
1150
  //#endregion
1068
1151
  //#region src/utils/validation.ts
1069
1152
  /**
@@ -1142,6 +1225,7 @@ const ConfigParamsSchema = zod_mini.z.object({
1142
1225
  dryRun: (0, zod_mini.optional)(zod_mini.z.boolean()),
1143
1226
  check: (0, zod_mini.optional)(zod_mini.z.boolean()),
1144
1227
  inputRoot: (0, zod_mini.optional)(zod_mini.z.string()),
1228
+ inputRoots: (0, zod_mini.optional)(zod_mini.z.array(zod_mini.z.string()).check((0, zod_mini.minLength)(1, "inputRoots must be non-empty"))),
1145
1229
  sources: (0, zod_mini.optional)(zod_mini.z.array(SourceEntrySchema))
1146
1230
  });
1147
1231
  zod_mini.z.partial(ConfigParamsSchema);
@@ -1154,14 +1238,39 @@ zod_mini.z.required(ConfigParamsSchema);
1154
1238
  * Normalizes the configuration file location to an absolute path.
1155
1239
  *
1156
1240
  * `ConfigResolver` always supplies the path it actually loaded; the fallback
1157
- * only covers direct programmatic construction, where the conventional
1158
- * location next to the input root is the best guess.
1241
+ * only covers direct programmatic construction. `anchorDir` is the directory
1242
+ * the config file lives next to for the default `.rulesync/` layout this
1243
+ * is the parent of the primary source tree.
1159
1244
  */
1160
- function normalizeConfigFilePath({ configFilePath, inputRoot }) {
1161
- if (configFilePath === void 0) return (0, node_path.join)(inputRoot, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1245
+ function normalizeConfigFilePath({ configFilePath, anchorDir }) {
1246
+ if (configFilePath === void 0) return (0, node_path.join)(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1162
1247
  return (0, node_path.isAbsolute)(configFilePath) ? configFilePath : (0, node_path.resolve)(configFilePath);
1163
1248
  }
1164
1249
  /**
1250
+ * Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
1251
+ * neither) to the canonical non-empty tuple of absolute paths that
1252
+ * `Config` stores. Relative entries are resolved against the current
1253
+ * working directory at call time.
1254
+ *
1255
+ * Semantics (post-refactor):
1256
+ * - Each entry in `inputRoots` is a rulesync **source tree** (the directory
1257
+ * that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
1258
+ * `.rulesync/` join is applied.
1259
+ * - The legacy singular `inputRoot` is a shorthand for "parent of the
1260
+ * default `.rulesync/` source tree", and is expanded to
1261
+ * `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
1262
+ * the ONLY place `.rulesync` is appended by convention.
1263
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1264
+ * so existing projects with a single `.rulesync/` tree keep working
1265
+ * unchanged.
1266
+ *
1267
+ * Callers must have already run `assertInputRootFieldsExclusive`.
1268
+ */
1269
+ function normalizeInputRoots({ inputRoot, inputRoots }) {
1270
+ const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [(0, node_path.join)(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => (0, node_path.isAbsolute)(entry) ? entry : (0, node_path.resolve)(entry));
1271
+ return [resolved[0], ...resolved.slice(1)];
1272
+ }
1273
+ /**
1165
1274
  * Conflicting target pairs that cannot be used together.
1166
1275
  * Exported so `rulesync doctor` can report the same conflicts as diagnostics
1167
1276
  * without duplicating the list.
@@ -1198,6 +1307,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
1198
1307
  if (targets !== void 0 && !Array.isArray(targets) && features !== void 0) throw new Error("Invalid config: when 'targets' is in object form, 'features' must be omitted. Declare per-target features inside the 'targets' object instead.");
1199
1308
  };
1200
1309
  /**
1310
+ * Rejects a single user-authored config file (or a single programmatic
1311
+ * construction) that defines both `inputRoot` and `inputRoots` — the two
1312
+ * fields express the same setting at singular vs. list level and cannot
1313
+ * be combined within one file without ambiguity.
1314
+ *
1315
+ * The check is intentionally per-file: base and local config files can each
1316
+ * be valid in isolation and merge into a state where both survive, and the
1317
+ * resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
1318
+ * Only a single file declaring both is a genuine authoring error.
1319
+ */
1320
+ const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
1321
+ if (inputRoot !== void 0 && inputRoots !== void 0) throw new Error("Invalid config: 'inputRoot' and 'inputRoots' cannot be combined. Remove 'inputRoot' and keep 'inputRoots', or reduce 'inputRoots' to a single-element 'inputRoot' string.");
1322
+ };
1323
+ /**
1324
+ * Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
1325
+ * selects the conventional default, while an empty list has no meaningful
1326
+ * source-tree semantics and must not be treated as an absent override.
1327
+ */
1328
+ const assertInputRootsNonEmpty = ({ inputRoots }) => {
1329
+ if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
1330
+ };
1331
+ /**
1201
1332
  * Normalizes a post-resolution `ConfigParams` input by rejecting the case
1202
1333
  * where both `targets` and `features` are undefined — a degenerate state
1203
1334
  * that would silently produce a no-op config (no targets, no features).
@@ -1233,14 +1364,35 @@ var Config = class Config {
1233
1364
  gitignoreDestination;
1234
1365
  dryRun;
1235
1366
  check;
1236
- inputRoot;
1367
+ /**
1368
+ * Ordered, absolute-path list of rulesync source trees. Each entry is a
1369
+ * source tree itself — the directory that directly contains `rules/`,
1370
+ * `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
1371
+ *
1372
+ * Always non-empty by construction — the constructor either normalizes
1373
+ * an `inputRoot`/`inputRoots` input or falls back to a single-element
1374
+ * list containing `join(<cwd>, ".rulesync")`.
1375
+ *
1376
+ * `inputRoot` (singular) is a deprecated backward-compatibility alias
1377
+ * that expands to `[join(inputRoot, ".rulesync")]`.
1378
+ *
1379
+ * Typed as a non-empty tuple so the one internal caller that legitimately
1380
+ * needs "the primary root" (`normalizeConfigFilePath` fallback) can index
1381
+ * `[0]` without a runtime null-check.
1382
+ */
1383
+ inputRoots;
1237
1384
  configFilePath;
1238
1385
  sources;
1239
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
1386
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
1240
1387
  assertTargetsFeaturesExclusive({
1241
1388
  targets,
1242
1389
  features
1243
1390
  });
1391
+ assertInputRootFieldsExclusive({
1392
+ inputRoot,
1393
+ inputRoots
1394
+ });
1395
+ assertInputRootsNonEmpty({ inputRoots });
1244
1396
  assertTargetsOrFeaturesProvided({
1245
1397
  targets,
1246
1398
  features
@@ -1268,10 +1420,13 @@ var Config = class Config {
1268
1420
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
1269
1421
  this.dryRun = dryRun ?? false;
1270
1422
  this.check = check ?? false;
1271
- this.inputRoot = inputRoot === void 0 ? process.cwd() : (0, node_path.isAbsolute)(inputRoot) ? inputRoot : (0, node_path.resolve)(inputRoot);
1423
+ this.inputRoots = normalizeInputRoots({
1424
+ inputRoot,
1425
+ inputRoots
1426
+ });
1272
1427
  this.configFilePath = normalizeConfigFilePath({
1273
1428
  configFilePath,
1274
- inputRoot: this.inputRoot
1429
+ anchorDir: (0, node_path.dirname)(this.inputRoots[0])
1275
1430
  });
1276
1431
  this.sources = sources ?? [];
1277
1432
  }
@@ -1455,16 +1610,22 @@ var Config = class Config {
1455
1610
  return this.check;
1456
1611
  }
1457
1612
  /**
1458
- * Returns the directory containing the `.rulesync/` source files. The value
1459
- * is always an absolute path captured at config-construction time, so this
1460
- * accessor is pure and never depends on a live `process.cwd()` read.
1613
+ * Returns the ordered list of rulesync source trees. Each entry is the
1614
+ * source tree itself the directory that directly contains `rules/`,
1615
+ * `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
1616
+ * config-construction time, so this accessor is pure and never depends on
1617
+ * a live `process.cwd()` read.
1461
1618
  *
1462
- * When no `inputRoot` was supplied to the constructor, `process.cwd()` is
1463
- * snapshotted once during construction. When a relative `inputRoot` is
1464
- * supplied, it is resolved to absolute against the construction-time cwd.
1619
+ * The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
1620
+ * was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
1621
+ * during construction. The first entry is the required base source tree.
1622
+ * Later entries are optional overlays and may be absent; when present, they
1623
+ * take precedence when the same relative source path exists in more than
1624
+ * one root (see per-feature merge policies in the processor
1625
+ * `loadRulesync*` methods).
1465
1626
  */
1466
- getInputRoot() {
1467
- return this.inputRoot;
1627
+ getInputRoots() {
1628
+ return this.inputRoots;
1468
1629
  }
1469
1630
  /**
1470
1631
  * Returns the absolute path of the configuration file this config was
@@ -1505,6 +1666,7 @@ const getDefaults = () => ({
1505
1666
  dryRun: false,
1506
1667
  check: false,
1507
1668
  inputRoot: void 0,
1669
+ inputRoots: void 0,
1508
1670
  sources: []
1509
1671
  });
1510
1672
  const loadConfigFromFile = async (filePath) => {
@@ -1516,8 +1678,23 @@ const loadConfigFromFile = async (filePath) => {
1516
1678
  targets: configParams.targets,
1517
1679
  features: configParams.features
1518
1680
  });
1681
+ try {
1682
+ assertInputRootFieldsExclusive({
1683
+ inputRoot: configParams.inputRoot,
1684
+ inputRoots: configParams.inputRoots
1685
+ });
1686
+ } catch (error) {
1687
+ const detail = error instanceof Error ? error.message : String(error);
1688
+ throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
1689
+ }
1519
1690
  return configParams;
1520
1691
  };
1692
+ function mergeInputRootConfigs({ baseConfig, localConfig }) {
1693
+ return {
1694
+ inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1695
+ inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
1696
+ };
1697
+ }
1521
1698
  const mergeConfigs = (baseConfig, localConfig) => {
1522
1699
  return {
1523
1700
  targets: localConfig.targets ?? baseConfig.targets,
@@ -1535,7 +1712,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
1535
1712
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
1536
1713
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
1537
1714
  check: localConfig.check ?? baseConfig.check,
1538
- inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1715
+ ...mergeInputRootConfigs({
1716
+ baseConfig,
1717
+ localConfig
1718
+ }),
1539
1719
  sources: localConfig.sources ?? baseConfig.sources
1540
1720
  };
1541
1721
  };
@@ -1564,13 +1744,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
1564
1744
  }
1565
1745
  }
1566
1746
  /**
1567
- * Resolve the effective `global` flag. When an `inputRoot` is in play the user
1568
- * is decoupling source from output, so a config-file `global: true` is dropped
1569
- * (unless the caller also explicitly passes `global`); a warning is emitted in
1747
+ * Resolve the effective `global` flag. When an input root (singular
1748
+ * `inputRoot` or plural `inputRoots`) is in play the user is decoupling
1749
+ * source from output, so a config-file `global: true` is dropped (unless
1750
+ * the caller also explicitly passes `global`); a warning is emitted in
1570
1751
  * that case. Returns the resolved boolean `global`.
1571
1752
  */
1572
1753
  function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
1573
- if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an inputRoot was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
1754
+ if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an input root was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
1574
1755
  return pick({
1575
1756
  cli: global,
1576
1757
  file: resolvedInputRoot !== void 0 ? false : configByFile.global,
@@ -1594,17 +1775,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
1594
1775
  resolvedTargets: userProvidedTargets ?? getDefaults().targets
1595
1776
  };
1596
1777
  }
1778
+ /**
1779
+ * Resolve the effective, non-empty, absolute-path list of source-tree roots
1780
+ * by applying CLI > file > default precedence and preferring `inputRoots`
1781
+ * over `inputRoot` when both survive the base+local merge. Duplicates (after
1782
+ * normalization to absolute paths) are removed silently so overlapping
1783
+ * base/local declarations do not double-count the same tree.
1784
+ *
1785
+ * Semantics (post-refactor):
1786
+ * - `inputRoots` entries are the source trees themselves (each holds
1787
+ * `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
1788
+ * unchanged.
1789
+ * - `inputRoot` (legacy singular) is a shorthand for "parent of the
1790
+ * `.rulesync/` source tree" and is expanded to `join(inputRoot,
1791
+ * ".rulesync")` before it hits any consumer.
1792
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1793
+ * so existing projects keep working unchanged.
1794
+ *
1795
+ * When both the merged file config has `inputRoots` and the CLI supplied
1796
+ * `inputRoot` (or vice versa), CLI wins outright — matching how every
1797
+ * other field is resolved. When only the file config supplies both, the
1798
+ * plural wins over the singular and the drop is logged at debug level.
1799
+ */
1800
+ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
1801
+ let source;
1802
+ let field;
1803
+ if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
1804
+ source = cliInputRoots;
1805
+ field = "inputRoots";
1806
+ } else if (cliInputRoot !== void 0) {
1807
+ source = [(0, node_path.join)(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1808
+ field = "inputRoot";
1809
+ } else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
1810
+ source = configByFile.inputRoots;
1811
+ field = "inputRoots";
1812
+ if (configByFile.inputRoot !== void 0) logger?.debug(`Both 'inputRoot' and 'inputRoots' were set after merging base and local configs; 'inputRoots' wins and 'inputRoot' was dropped.`);
1813
+ } else if (configByFile.inputRoot !== void 0) {
1814
+ source = [(0, node_path.join)(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1815
+ field = "inputRoot";
1816
+ } else source = [(0, node_path.join)(cwd, RULESYNC_RELATIVE_DIR_PATH)];
1817
+ const candidates = source.map((entry) => (0, node_path.resolve)(cwd, entry));
1818
+ const seen = /* @__PURE__ */ new Set();
1819
+ const resolved = [];
1820
+ for (const absolute of candidates) {
1821
+ if (seen.has(absolute)) continue;
1822
+ seen.add(absolute);
1823
+ resolved.push(absolute);
1824
+ }
1825
+ return {
1826
+ inputRoots: [resolved[0], ...resolved.slice(1)],
1827
+ candidates,
1828
+ field
1829
+ };
1830
+ }
1597
1831
  var ConfigResolver = class {
1598
- static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
1832
+ static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
1599
1833
  const cwd = (0, node_path.resolve)(process.cwd());
1834
+ assertInputRootFieldsExclusive({
1835
+ inputRoot,
1836
+ inputRoots
1837
+ });
1838
+ assertInputRootsNonEmpty({ inputRoots });
1600
1839
  if (inputRoot !== void 0) validateOutputRoot(inputRoot);
1601
- const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(inputRoot ?? cwd));
1840
+ if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
1841
+ const cliConfigAnchor = inputRoot;
1842
+ const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
1843
+ const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(cliConfigAnchor ?? cwd));
1602
1844
  const baseConfig = await loadConfigFromFile(validatedConfigPath);
1603
1845
  const configDir = (0, node_path.dirname)(validatedConfigPath);
1604
1846
  const localConfigPath = (0, node_path.join)(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
1605
1847
  const localConfig = await loadConfigFromFile(localConfigPath);
1606
1848
  const configByFile = mergeConfigs(baseConfig, localConfig);
1607
- if (inputRoot === void 0 && configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1849
+ if (!hasCliInputRootOverride) {
1850
+ if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1851
+ if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
1852
+ }
1608
1853
  assertMergedTargetsFeaturesExclusive({
1609
1854
  configByFile,
1610
1855
  validatedConfigPath,
@@ -1630,10 +1875,16 @@ var ConfigResolver = class {
1630
1875
  silent: resolvedSilent
1631
1876
  });
1632
1877
  }
1633
- const resolvedInputRoot = inputRoot ?? configByFile.inputRoot;
1878
+ const resolvedInputRoots = resolveEffectiveInputRoots({
1879
+ cliInputRoot: inputRoot,
1880
+ cliInputRoots: inputRoots,
1881
+ configByFile,
1882
+ cwd,
1883
+ logger
1884
+ }).inputRoots;
1634
1885
  const resolvedGlobal = resolveGlobal({
1635
1886
  logger,
1636
- resolvedInputRoot,
1887
+ resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
1637
1888
  global,
1638
1889
  configByFile,
1639
1890
  validatedConfigPath
@@ -1697,7 +1948,7 @@ var ConfigResolver = class {
1697
1948
  file: configByFile.check,
1698
1949
  fallback: getDefaults().check
1699
1950
  }),
1700
- inputRoot: resolvedInputRoot !== void 0 ? (0, node_path.resolve)(resolvedInputRoot) : cwd,
1951
+ inputRoots: resolvedInputRoots,
1701
1952
  configFilePath: validatedConfigPath,
1702
1953
  sources: configByFile.sources ?? getDefaults().sources,
1703
1954
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
@@ -1840,8 +2091,7 @@ var AiFile = class {
1840
2091
  const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
1841
2092
  const resolvedFull = (0, node_path.resolve)(fullPath);
1842
2093
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
1843
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
1844
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
2094
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
1845
2095
  return fullPath;
1846
2096
  }
1847
2097
  getFileContent() {
@@ -1903,6 +2153,27 @@ var RulesyncFile = class extends AiFile {
1903
2153
  }
1904
2154
  };
1905
2155
  //#endregion
2156
+ //#region src/utils/control-characters.ts
2157
+ /**
2158
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
2159
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
2160
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
2161
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
2162
+ * settings file must never reach the terminal with these intact: they let the
2163
+ * text forge log lines, reorder what is printed around them, or inject escape
2164
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
2165
+ * neutral characters beside them, so they go too — a diagnostic line is not the
2166
+ * place to preserve the typography of a right-to-left name.
2167
+ */
2168
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
2169
+ /**
2170
+ * Removes every control character from `text` so it is safe to splice into a
2171
+ * log line or other terminal output.
2172
+ */
2173
+ function stripControlCharacters(text) {
2174
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
2175
+ }
2176
+ //#endregion
1906
2177
  //#region src/utils/type-guards.ts
1907
2178
  /**
1908
2179
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -2026,7 +2297,7 @@ function parseFrontmatter(content, filePath) {
2026
2297
  let body;
2027
2298
  let hasFrontmatter;
2028
2299
  try {
2029
- const result = (0, gray_matter.default)(content);
2300
+ const result = (0, gray_matter.default)(content, {});
2030
2301
  frontmatter = result.data;
2031
2302
  body = result.content;
2032
2303
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
@@ -2040,6 +2311,104 @@ function parseFrontmatter(content, filePath) {
2040
2311
  hasFrontmatter
2041
2312
  };
2042
2313
  }
2314
+ /**
2315
+ * A top-level `key: value` entry. Nested entries are left alone deliberately:
2316
+ * the repair below rewrites a value's meaning, and the failure it exists for —
2317
+ * an unquoted sentence with a colon in it — is a `description`, which is always
2318
+ * top-level.
2319
+ */
2320
+ const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
2321
+ /** A plain scalar that already starts as some other YAML construct. */
2322
+ const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
2323
+ /**
2324
+ * Cut a plain scalar at its inline comment — whitespace followed by `#`.
2325
+ *
2326
+ * This has to happen before anything else looks at the value, or a line such as
2327
+ * `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
2328
+ * back quoted with the comment inside it, which for a list of tool permissions
2329
+ * would grant what the comment had disabled. Scanning by hand rather than with
2330
+ * `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
2331
+ * after it backtracks from every starting position, and a value padded with a
2332
+ * megabyte of spaces takes minutes to reject. This is linear in the value.
2333
+ */
2334
+ function stripInlineComment(rawValue) {
2335
+ for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
2336
+ return rawValue.trimEnd();
2337
+ }
2338
+ function repairFrontmatterLine(line) {
2339
+ const unchanged = {
2340
+ line,
2341
+ droppedComment: false
2342
+ };
2343
+ const carriageReturn = line.endsWith("\r") ? "\r" : "";
2344
+ const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
2345
+ const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
2346
+ if (!match) return unchanged;
2347
+ const [, key = "", rawValue = ""] = match;
2348
+ const value = stripInlineComment(rawValue);
2349
+ if (value === "") return unchanged;
2350
+ if (!/:(?:\s|$)/.test(value)) return unchanged;
2351
+ if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
2352
+ return {
2353
+ line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
2354
+ droppedComment: value !== rawValue.trimEnd()
2355
+ };
2356
+ }
2357
+ /**
2358
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
2359
+ * return `undefined` when there is nothing to repair. Only the frontmatter
2360
+ * block is rewritten; the body is passed through untouched.
2361
+ */
2362
+ function repairMalformedFrontmatterYaml(content) {
2363
+ const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
2364
+ if (!opening) return;
2365
+ const blockStart = opening[0].length;
2366
+ const closing = /\r?\n---/.exec(content.slice(blockStart));
2367
+ if (!closing) return;
2368
+ const blockEnd = blockStart + closing.index;
2369
+ const block = content.slice(blockStart, blockEnd);
2370
+ const repairedLines = block.split("\n").map(repairFrontmatterLine);
2371
+ const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
2372
+ if (repairedBlock === block) return;
2373
+ return {
2374
+ content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
2375
+ droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
2376
+ };
2377
+ }
2378
+ /**
2379
+ * Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
2380
+ *
2381
+ * Files authored for another client routinely carry YAML that only that
2382
+ * client's parser accepts — `description: Use this skill when: the user asks
2383
+ * about PDFs` is the case the Agent Skills client guide names. Without a retry
2384
+ * such a file is not merely reported, it is dropped: the lenient skill import
2385
+ * catches the parse error and skips the whole skill. The retry is deliberately
2386
+ * narrow — one pass, top-level entries only, and the original error is what
2387
+ * surfaces if it does not help, so a genuinely broken file still fails with the
2388
+ * message that describes what is actually wrong with it. A file with no closing
2389
+ * `---`, or one whose opening fence carries a language tag, is not repaired at
2390
+ * all: neither is a frontmatter block gray-matter would have read.
2391
+ *
2392
+ * @see https://agentskills.io/client-implementation/adding-skills-support
2393
+ */
2394
+ function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
2395
+ try {
2396
+ return parseFrontmatter(content, filePath);
2397
+ } catch (error) {
2398
+ const repaired = repairMalformedFrontmatterYaml(content);
2399
+ if (repaired === void 0) throw error;
2400
+ let result;
2401
+ try {
2402
+ result = parseFrontmatter(repaired.content, filePath);
2403
+ } catch {
2404
+ throw error;
2405
+ }
2406
+ if (options.quiet === true) return result;
2407
+ const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
2408
+ warnOnceWithFallback(void 0, `Recovered malformed YAML frontmatter in ${filePath === void 0 ? "the input" : stripControlCharacters(toPosixPath(filePath))} by quoting values that contain a colon.${commentNote} Quote them in the file itself so other tools can read it too.`);
2409
+ return result;
2410
+ }
2411
+ }
2043
2412
  //#endregion
2044
2413
  //#region src/features/checks/rulesync-check.ts
2045
2414
  const RulesyncCheckFrontmatterSchema = zod_mini.z.looseObject({
@@ -2097,8 +2466,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2097
2466
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2098
2467
  };
2099
2468
  }
2100
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2101
- const filePath = (0, node_path.join)(outputRoot, RULESYNC_CHECKS_RELATIVE_DIR_PATH, relativeFilePath);
2469
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2470
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
2471
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
2102
2472
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
2103
2473
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
2104
2474
  const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
@@ -2106,7 +2476,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2106
2476
  const filename = (0, node_path.basename)(relativeFilePath);
2107
2477
  return new RulesyncCheck({
2108
2478
  outputRoot,
2109
- relativeDirPath: this.getSettablePaths().relativeDirPath,
2479
+ relativeDirPath: dirPath,
2110
2480
  relativeFilePath: filename,
2111
2481
  frontmatter: result.data,
2112
2482
  body: content.trim()
@@ -2177,8 +2547,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2177
2547
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2178
2548
  };
2179
2549
  }
2180
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2181
- const filePath = (0, node_path.join)(outputRoot, RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath);
2550
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2551
+ const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
2552
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
2182
2553
  const fileContent = await readFileContent(filePath);
2183
2554
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
2184
2555
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
@@ -2186,7 +2557,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2186
2557
  if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
2187
2558
  return new RulesyncCommand({
2188
2559
  outputRoot,
2189
- relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
2560
+ relativeDirPath: dirPath,
2190
2561
  relativeFilePath,
2191
2562
  frontmatter: result.data,
2192
2563
  body: content.trim(),
@@ -3575,8 +3946,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
3575
3946
  };
3576
3947
  /**
3577
3948
  * Map Mistral Vibe snake_case event names to canonical camelCase.
3949
+ *
3950
+ * The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
3951
+ * strict `HookType` enum rejects a file that still uses them, so such a file is
3952
+ * already dead on disk; reading it here and emitting the renamed spelling is
3953
+ * what repairs it, whereas leaving the old name unmapped would route the hook
3954
+ * into a tool override block and lose the event.
3578
3955
  */
3579
- const VIBE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k]));
3956
+ const VIBE_TO_CANONICAL_EVENT_NAMES = {
3957
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
3958
+ before_tool: "preToolUse",
3959
+ after_tool: "postToolUse",
3960
+ post_agent_turn: "stop"
3961
+ };
3580
3962
  /**
3581
3963
  * Map canonical camelCase event names to Qwen Code PascalCase.
3582
3964
  *
@@ -3797,15 +4179,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
3797
4179
  error: null
3798
4180
  };
3799
4181
  }
3800
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
4182
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
3801
4183
  const paths = RulesyncHooks.getSettablePaths();
4184
+ const overrideDirPath = relativeDirPath;
3802
4185
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
3803
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4186
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4187
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
3804
4188
  if (!await fileExists(filePath)) continue;
3805
4189
  const fileContent = await readFileContent(filePath);
3806
4190
  return new RulesyncHooks({
3807
4191
  outputRoot,
3808
- relativeDirPath: candidate.relativeDirPath,
4192
+ relativeDirPath: candidateDirPath,
3809
4193
  relativeFilePath: candidate.relativeFilePath,
3810
4194
  fileContent,
3811
4195
  validate
@@ -3832,21 +4216,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3832
4216
  relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
3833
4217
  relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
3834
4218
  },
3835
- legacy: {
4219
+ legacy: [{
3836
4220
  relativeDirPath: ".",
3837
4221
  relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
3838
- }
4222
+ }]
3839
4223
  };
3840
4224
  }
3841
- static async fromFile({ outputRoot = process.cwd() } = {}) {
4225
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
3842
4226
  const paths = this.getSettablePaths();
3843
- const recommendedPath = (0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
3844
- const legacyPath = (0, node_path.join)(outputRoot, paths.legacy.relativeDirPath, paths.legacy.relativeFilePath);
4227
+ const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
4228
+ const recommendedPath = (0, node_path.join)(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
4229
+ const [legacy] = paths.legacy;
4230
+ const legacyPath = (0, node_path.join)(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
3845
4231
  if (await fileExists(recommendedPath)) {
3846
4232
  const fileContent = await readFileContent(recommendedPath);
3847
4233
  return new RulesyncIgnore({
3848
4234
  outputRoot,
3849
- relativeDirPath: paths.recommended.relativeDirPath,
4235
+ relativeDirPath: recommendedDirPath,
3850
4236
  relativeFilePath: paths.recommended.relativeFilePath,
3851
4237
  fileContent
3852
4238
  });
@@ -3855,15 +4241,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3855
4241
  const fileContent = await readFileContent(legacyPath);
3856
4242
  return new RulesyncIgnore({
3857
4243
  outputRoot,
3858
- relativeDirPath: paths.legacy.relativeDirPath,
3859
- relativeFilePath: paths.legacy.relativeFilePath,
4244
+ relativeDirPath: legacy.relativeDirPath,
4245
+ relativeFilePath: legacy.relativeFilePath,
3860
4246
  fileContent
3861
4247
  });
3862
4248
  }
3863
4249
  const fileContent = await readFileContent(recommendedPath);
3864
4250
  return new RulesyncIgnore({
3865
4251
  outputRoot,
3866
- relativeDirPath: paths.recommended.relativeDirPath,
4252
+ relativeDirPath: recommendedDirPath,
3867
4253
  relativeFilePath: paths.recommended.relativeFilePath,
3868
4254
  fileContent
3869
4255
  });
@@ -4019,6 +4405,118 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
4019
4405
  warp: zod_mini.z.optional(toolScopedMcpSchema),
4020
4406
  zed: zod_mini.z.optional(toolScopedMcpSchema)
4021
4407
  });
4408
+ /**
4409
+ * The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
4410
+ * Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
4411
+ * from the schema — every tool-scoped block declared above is treated as a
4412
+ * "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
4413
+ * (including `$schema` and top-level Kimi Code timeout fields) is replaced
4414
+ * atomically.
4415
+ */
4416
+ const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
4417
+ /**
4418
+ * Return the first candidate path (recommended, then legacy variants) that
4419
+ * exists under `outputRoot`, or `undefined` when none is present. Shared
4420
+ * between `fromFile` (single-root) and `fromRoots` (multi-root) so both
4421
+ * paths honour the same intra-root resolution order.
4422
+ *
4423
+ * When `overrideDirPath` is provided it replaces the candidates'
4424
+ * class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
4425
+ * caller can point at a non-default source tree (e.g. `.rulesync.local/`).
4426
+ * Also returned is the effective `relativeDirPath` for the winning
4427
+ * candidate so the caller can reconstruct a `RulesyncMcp` with matching
4428
+ * anchor fields.
4429
+ */
4430
+ async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
4431
+ for (const candidate of getRulesyncSourceCandidates({ paths })) {
4432
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4433
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
4434
+ if (await fileExists(filePath)) return {
4435
+ filePath,
4436
+ candidate: {
4437
+ relativeDirPath: candidateDirPath,
4438
+ relativeFilePath: candidate.relativeFilePath
4439
+ }
4440
+ };
4441
+ }
4442
+ }
4443
+ /**
4444
+ * Merge two parsed MCP JSON objects with the one-level policy from the
4445
+ * inputRoots plan: the top-level `mcpServers` map and each
4446
+ * `<toolname>.mcpServers` sub-map are merged by server name (later wins per
4447
+ * key). Every other value — individual server configs, other top-level keys
4448
+ * — is replaced atomically. This keeps the merge predictable: an overlay can
4449
+ * add or replace whole shared servers, but a partial patch of one server's
4450
+ * `args`/`env` is deliberately not supported.
4451
+ */
4452
+ function getRecordField({ value, path }) {
4453
+ if (value === void 0) return {};
4454
+ if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
4455
+ return value;
4456
+ }
4457
+ /**
4458
+ * Overlay one record onto another, dropping any overlay key that could reach
4459
+ * `Object.prototype`. Every overlay merge goes through this so a `__proto__`
4460
+ * entry cannot enter the merged config from any depth — top-level
4461
+ * `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
4462
+ */
4463
+ function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
4464
+ const merged = { ...base };
4465
+ for (const [key, value] of Object.entries(overlay)) {
4466
+ if (isPrototypePollutionKey(key)) continue;
4467
+ merged[key] = value;
4468
+ }
4469
+ return merged;
4470
+ }
4471
+ function mergeMcpJsonOverlays({ base, overlay }) {
4472
+ const merged = { ...base };
4473
+ for (const [key, overlayValue] of Object.entries(overlay)) {
4474
+ if (isPrototypePollutionKey(key)) continue;
4475
+ if (key === "mcpServers") {
4476
+ merged.mcpServers = mergeRecordsSkippingPollutionKeys({
4477
+ base: getRecordField({
4478
+ value: base.mcpServers,
4479
+ path: "mcpServers"
4480
+ }),
4481
+ overlay: getRecordField({
4482
+ value: overlayValue,
4483
+ path: "mcpServers"
4484
+ })
4485
+ });
4486
+ continue;
4487
+ }
4488
+ if (TOOL_SCOPED_MCP_KEYS.has(key)) {
4489
+ const baseBlock = getRecordField({
4490
+ value: base[key],
4491
+ path: key
4492
+ });
4493
+ const overlayBlock = getRecordField({
4494
+ value: overlayValue,
4495
+ path: key
4496
+ });
4497
+ const mergedBlock = mergeRecordsSkippingPollutionKeys({
4498
+ base: baseBlock,
4499
+ overlay: overlayBlock
4500
+ });
4501
+ const baseServers = getRecordField({
4502
+ value: baseBlock.mcpServers,
4503
+ path: `${key}.mcpServers`
4504
+ });
4505
+ const overlayServers = getRecordField({
4506
+ value: overlayBlock.mcpServers,
4507
+ path: `${key}.mcpServers`
4508
+ });
4509
+ if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
4510
+ base: baseServers,
4511
+ overlay: overlayServers
4512
+ });
4513
+ merged[key] = mergedBlock;
4514
+ continue;
4515
+ }
4516
+ merged[key] = overlayValue;
4517
+ }
4518
+ return merged;
4519
+ }
4022
4520
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4023
4521
  json;
4024
4522
  constructor(params) {
@@ -4055,28 +4553,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4055
4553
  error: null
4056
4554
  };
4057
4555
  }
4058
- static async fromFile({ outputRoot = process.cwd(), validate = true, logger }) {
4556
+ /**
4557
+ * Load and merge MCP source files across the configured input roots.
4558
+ *
4559
+ * `inputRoots` entries are the source trees themselves (e.g.
4560
+ * `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
4561
+ * `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
4562
+ * legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
4563
+ * source tree and the first hit is loaded. Roots that have no candidate
4564
+ * contribute nothing.
4565
+ *
4566
+ * Cross-root behavior: the parsed JSON objects are folded left-to-right
4567
+ * with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
4568
+ * server name (one level deep) and replace every other value atomically.
4569
+ * With one root, this delegates to `fromFile` so JSONC formatting and the
4570
+ * actual candidate path are preserved. With multiple roots, each source is
4571
+ * parsed and schema-validated before merging so failures name the originating
4572
+ * file. The merged object is necessarily synthetic, serialized JSON anchored
4573
+ * to the first root's recommended path.
4574
+ *
4575
+ * A multi-root configuration where only one root actually supplies a file is
4576
+ * treated as the single-root case: nothing is merged, so the original file
4577
+ * content is kept verbatim (preserving JSONC comments) and the instance is
4578
+ * anchored at the root that supplied it rather than at the primary root's
4579
+ * recommended path.
4580
+ *
4581
+ * When no root supplies any candidate, this falls back to reading the
4582
+ * primary root's recommended path so the underlying file-not-found error
4583
+ * matches the single-root behavior of `fromFile`.
4584
+ */
4585
+ static async fromRoots({ inputRoots, validate = true, logger }) {
4586
+ if (inputRoots.length === 1) {
4587
+ const [primary] = inputRoots;
4588
+ return this.fromFile({
4589
+ outputRoot: (0, node_path.dirname)(primary),
4590
+ relativeDirPath: (0, node_path.basename)(primary),
4591
+ validate,
4592
+ logger
4593
+ });
4594
+ }
4059
4595
  const paths = this.getSettablePaths();
4596
+ const rootSources = [];
4597
+ for (const root of inputRoots) {
4598
+ const parent = (0, node_path.dirname)(root);
4599
+ const treeName = (0, node_path.basename)(root);
4600
+ const found = await findFirstExistingCandidate({
4601
+ paths,
4602
+ outputRoot: parent,
4603
+ overrideDirPath: treeName
4604
+ });
4605
+ if (found === void 0) continue;
4606
+ const { filePath, candidate } = found;
4607
+ if (filePath.endsWith(".mcp.json")) {
4608
+ const recommendedPath = (0, node_path.join)(parent, treeName, paths.recommended.relativeFilePath);
4609
+ logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4610
+ }
4611
+ const fileContent = await readFileContent(filePath);
4612
+ let parsed;
4613
+ try {
4614
+ parsed = parseJsonc$8(fileContent);
4615
+ if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
4616
+ if (validate) {
4617
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
4618
+ if (!result.success) throw result.error;
4619
+ }
4620
+ } catch (error) {
4621
+ throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
4622
+ }
4623
+ rootSources.push({
4624
+ record: parsed,
4625
+ outputRoot: parent,
4626
+ relativeDirPath: candidate.relativeDirPath,
4627
+ relativeFilePath: candidate.relativeFilePath,
4628
+ fileContent
4629
+ });
4630
+ }
4631
+ if (rootSources.length === 0) {
4632
+ const primary = inputRoots[0];
4633
+ return this.fromFile({
4634
+ outputRoot: (0, node_path.dirname)(primary),
4635
+ relativeDirPath: (0, node_path.basename)(primary),
4636
+ validate,
4637
+ logger
4638
+ });
4639
+ }
4640
+ const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
4641
+ if (onlySource !== void 0) return new RulesyncMcp({
4642
+ outputRoot: onlySource.outputRoot,
4643
+ relativeDirPath: onlySource.relativeDirPath,
4644
+ relativeFilePath: onlySource.relativeFilePath,
4645
+ fileContent: onlySource.fileContent,
4646
+ validate
4647
+ });
4648
+ const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
4649
+ base: acc,
4650
+ overlay: next.record
4651
+ }), {});
4652
+ const primary = inputRoots[0];
4653
+ return new RulesyncMcp({
4654
+ outputRoot: (0, node_path.dirname)(primary),
4655
+ relativeDirPath: (0, node_path.basename)(primary),
4656
+ relativeFilePath: paths.recommended.relativeFilePath,
4657
+ fileContent: JSON.stringify(merged, null, 2),
4658
+ validate
4659
+ });
4660
+ }
4661
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
4662
+ const paths = this.getSettablePaths();
4663
+ const overrideDirPath = relativeDirPath;
4060
4664
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
4061
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4665
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4666
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
4062
4667
  if (!await fileExists(filePath)) continue;
4063
4668
  if (candidate.relativeFilePath === ".mcp.json") {
4064
- const recommendedPath = (0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
4669
+ const recommendedPath = (0, node_path.join)(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
4065
4670
  logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4066
4671
  }
4067
4672
  const fileContent = await readFileContent(filePath);
4068
4673
  return new RulesyncMcp({
4069
4674
  outputRoot,
4070
- relativeDirPath: candidate.relativeDirPath,
4675
+ relativeDirPath: candidateDirPath,
4071
4676
  relativeFilePath: candidate.relativeFilePath,
4072
4677
  fileContent,
4073
4678
  validate
4074
4679
  });
4075
4680
  }
4076
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath));
4681
+ const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
4682
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
4077
4683
  return new RulesyncMcp({
4078
4684
  outputRoot,
4079
- relativeDirPath: paths.recommended.relativeDirPath,
4685
+ relativeDirPath: fallbackDirPath,
4080
4686
  relativeFilePath: paths.recommended.relativeFilePath,
4081
4687
  fileContent,
4082
4688
  validate
@@ -5271,15 +5877,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5271
5877
  error: null
5272
5878
  };
5273
5879
  }
5274
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5880
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5275
5881
  const paths = RulesyncPermissions.getSettablePaths();
5882
+ const overrideDirPath = relativeDirPath;
5276
5883
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5277
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
5884
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
5885
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
5278
5886
  if (!await fileExists(filePath)) continue;
5279
5887
  const fileContent = await readFileContent(filePath);
5280
5888
  return new RulesyncPermissions({
5281
5889
  outputRoot,
5282
- relativeDirPath: candidate.relativeDirPath,
5890
+ relativeDirPath: candidateDirPath,
5283
5891
  relativeFilePath: candidate.relativeFilePath,
5284
5892
  fileContent,
5285
5893
  validate
@@ -5443,8 +6051,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5443
6051
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5444
6052
  };
5445
6053
  }
5446
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5447
- const filePath = (0, node_path.join)(outputRoot, this.getSettablePaths().recommended.relativeDirPath, relativeFilePath);
6054
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
6055
+ const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
6056
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
5448
6057
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5449
6058
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5450
6059
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5457,7 +6066,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5457
6066
  };
5458
6067
  return new RulesyncRule({
5459
6068
  outputRoot,
5460
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
6069
+ relativeDirPath: dirPath,
5461
6070
  relativeFilePath,
5462
6071
  frontmatter: validatedFrontmatter,
5463
6072
  body: content.trim(),
@@ -5469,8 +6078,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5469
6078
  }
5470
6079
  };
5471
6080
  //#endregion
6081
+ //#region src/utils/concurrency.ts
6082
+ /**
6083
+ * Map over items with a bounded number of operations in flight.
6084
+ *
6085
+ * `Promise.all(items.map(...))` starts every operation at once, which is fine
6086
+ * for a handful of paths and not fine for a directory tree of unknown size: a
6087
+ * few thousand concurrent `realpath` calls queue on the libuv thread pool and
6088
+ * hold their closures alive while they wait. Results keep the input order.
6089
+ *
6090
+ * `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
6091
+ * wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
6092
+ * around it, so every item's promise chain is allocated up front. This walks a
6093
+ * shared cursor with `limit` workers instead, so a list of unknown size costs
6094
+ * `limit` pending operations rather than one per item.
6095
+ */
6096
+ async function mapWithConcurrency({ items, limit, mapper }) {
6097
+ const results = Array.from({ length: items.length });
6098
+ let nextIndex = 0;
6099
+ const runWorker = async () => {
6100
+ while (nextIndex < items.length) {
6101
+ const index = nextIndex;
6102
+ nextIndex += 1;
6103
+ const item = items[index];
6104
+ if (item === void 0) continue;
6105
+ results[index] = await mapper(item);
6106
+ }
6107
+ };
6108
+ const workerCount = Math.max(1, Math.min(limit, items.length));
6109
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
6110
+ return results;
6111
+ }
6112
+ //#endregion
5472
6113
  //#region src/types/ai-dir.ts
5473
- var AiDir = class {
6114
+ /**
6115
+ * Directories that hold credentials. Excluding these protects something, so
6116
+ * their exclusion is reported rather than silent.
6117
+ */
6118
+ const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
6119
+ ".ssh",
6120
+ ".aws",
6121
+ ".gnupg"
6122
+ ]);
6123
+ /**
6124
+ * Directories that are refused only when the path leaves the skill directory to
6125
+ * reach them. These are the per-application trees of a home directory, where
6126
+ * naming every credential file is a list always one release behind -- `gcloud`
6127
+ * alone writes `credentials.db` and `application_default_credentials.json`, and
6128
+ * `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
6129
+ * own still carries it: what is refused is a link that reaches the *user's*.
6130
+ *
6131
+ * A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
6132
+ * the global skills this feature exists to share, so refusing a link that
6133
+ * reaches one would refuse the ordinary case along with the bad one.
6134
+ */
6135
+ const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
6136
+ ".config",
6137
+ ".local",
6138
+ ".azure",
6139
+ ".m2",
6140
+ ".terraform.d",
6141
+ ".docker",
6142
+ ".kube",
6143
+ "keychains"
6144
+ ]);
6145
+ /**
6146
+ * Whether an escaping real path passes through a directory only reachable by
6147
+ * escaping.
6148
+ *
6149
+ * Only the segments *past* the skill directory count. A global skill lives at
6150
+ * `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
6151
+ * the whole real path would refuse a link that never left the skills tree it
6152
+ * was already in -- the shared-directory case, reported as a credential.
6153
+ */
6154
+ function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
6155
+ const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
6156
+ const dirSegments = splitPathSegments(realDirPath).map(normalize);
6157
+ const fileSegments = splitPathSegments(realFilePath).map(normalize);
6158
+ let shared = 0;
6159
+ while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
6160
+ return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
6161
+ }
6162
+ /**
6163
+ * Directories that are never part of a skill for the ordinary reasons: a
6164
+ * nested repository, or a build/cache tree. Leaving these out is what a user
6165
+ * expects, so it happens quietly.
6166
+ */
6167
+ const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
6168
+ ".git",
6169
+ ".hg",
6170
+ ".svn",
6171
+ ".cache",
6172
+ ".venv",
6173
+ ".tox",
6174
+ ".mypy_cache",
6175
+ ".pytest_cache",
6176
+ ".ruff_cache",
6177
+ ".gradle",
6178
+ ".next",
6179
+ ".nuxt",
6180
+ ".turbo",
6181
+ ".parcel-cache",
6182
+ ".nyc_output",
6183
+ ".terraform"
6184
+ ]);
6185
+ /** Files that hold credentials. Compared lower-cased. */
6186
+ const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
6187
+ ".npmrc",
6188
+ ".netrc",
6189
+ ".git-credentials",
6190
+ ".pgpass",
6191
+ ".pypirc",
6192
+ ".htpasswd",
6193
+ ".dockercfg",
6194
+ ".envrc"
6195
+ ]);
6196
+ /** Files that are local noise. Compared lower-cased. */
6197
+ const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
6198
+ /** Credential files whose parent directory is otherwise ordinary content. */
6199
+ const NEVER_CARRIED_PATH_SUFFIXES = [
6200
+ ".docker/config.json",
6201
+ ".kube/config",
6202
+ ".config/gh/hosts.yml",
6203
+ ".config/gcloud/credentials.db",
6204
+ ".gem/credentials",
6205
+ "gcloud/application_default_credentials.json",
6206
+ ".codex/auth.json",
6207
+ ".gemini/oauth_creds.json"
6208
+ ];
6209
+ /** Whether a path ends in one of the credential files named above. */
6210
+ function endsWithNeverCarriedSuffix(filePath) {
6211
+ const posixPath = toPosixPath(filePath).toLowerCase();
6212
+ return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
6213
+ }
6214
+ /**
6215
+ * `.env.<suffix>` spellings that are templates rather than real values.
6216
+ * Everything else matching `.env*` is treated as holding secrets, because
6217
+ * `.env.production` is no less sensitive than `.env` itself.
6218
+ */
6219
+ const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
6220
+ "example",
6221
+ "sample",
6222
+ "template",
6223
+ "dist",
6224
+ "defaults"
6225
+ ]);
6226
+ /**
6227
+ * Kernel pseudo-filesystems, matched against the resolved real path. A link
6228
+ * into one of these does not reach a file at all: `/proc/self/environ` reads
6229
+ * back the entire environment of the running process, API keys included, and
6230
+ * `stat` reports it as an ordinary file. Nothing a skill carries lives here.
6231
+ */
6232
+ const NEVER_CARRIED_REAL_PATH_ROOTS = [
6233
+ "/proc",
6234
+ "/sys",
6235
+ "/dev"
6236
+ ];
6237
+ /**
6238
+ * Whether a directory of this name is pruned during the walk, so it is never
6239
+ * descended into at all. Derived from the directory names above so the pruning
6240
+ * and the path check below cannot drift apart.
6241
+ */
6242
+ function isNeverCarriedDirName(dirName) {
6243
+ const normalized = normalizePathSegment(dirName.toLowerCase());
6244
+ return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
6245
+ }
6246
+ /**
6247
+ * Windows drops trailing dots and spaces from a name, so a file called `.env `
6248
+ * is written as `.env` once it lands in a tool directory there. Normalizing
6249
+ * before every comparison means the name is judged as what it becomes.
6250
+ */
6251
+ function normalizePathSegment(segment) {
6252
+ const normalized = segment.replace(/[\s.]+$/, "");
6253
+ return normalized === "" ? segment : normalized;
6254
+ }
6255
+ /**
6256
+ * Why a path reaches something that is never skill content, or `undefined`
6257
+ * when it does not.
6258
+ *
6259
+ * Carrying hidden entries means a secret sitting in a skill directory would be
6260
+ * copied into every enabled tool root, multiplying the places it can be
6261
+ * committed from, and a `.venv` would be copied file by file into each of them.
6262
+ * None of these names is ever skill content, so excluding them costs nothing.
6263
+ *
6264
+ * The check is applied to the resolved real path as well as the literal one:
6265
+ * the names are what makes an entry dangerous, and a symbolic link named
6266
+ * `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
6267
+ * `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
6268
+ * and `.ssh` to the same file.
6269
+ */
6270
+ function classifyNeverCarried(relativePath) {
6271
+ const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
6272
+ const fileName = segments.at(-1) ?? "";
6273
+ const posixPath = segments.join("/");
6274
+ if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
6275
+ if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
6276
+ if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
6277
+ if (isCredentialFileName(fileName)) return "credential";
6278
+ if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
6279
+ if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
6280
+ }
6281
+ /**
6282
+ * Whether a single path segment names a credential file, whether it is the file
6283
+ * itself or a directory somebody gave that name to.
6284
+ */
6285
+ function isCredentialFileName(segment) {
6286
+ const name = normalizePathSegment(segment.toLowerCase());
6287
+ if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
6288
+ for (const base of [".env", ".envrc"]) {
6289
+ if (name === base) return true;
6290
+ if (name.startsWith(`${base}.`)) {
6291
+ const lastPiece = name.split(".").at(-1) ?? "";
6292
+ return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
6293
+ }
6294
+ }
6295
+ return false;
6296
+ }
6297
+ /** Whether a resolved real path points into a kernel pseudo-filesystem. */
6298
+ function isSystemPseudoPath(absolutePath) {
6299
+ const posixPath = toPosixPath(absolutePath);
6300
+ return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
6301
+ }
6302
+ /** How many links a chain may be followed before it is treated as a loop. */
6303
+ const MAX_LINK_CHAIN_HOPS = 40;
6304
+ /**
6305
+ * Whether reaching a path goes through a kernel pseudo-filesystem, even when it
6306
+ * does not end in one.
6307
+ *
6308
+ * Asking `realpath` alone is not enough, and the entries it is not enough for
6309
+ * are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
6310
+ * links, so resolving them lands *outside* `/proc`, on whatever file the
6311
+ * process happens to hold open — a private key another program is reading right
6312
+ * now would come back as an ordinary path and be carried. Following the chain a
6313
+ * hop at a time, and checking each hop, is what sees the `/proc` in the middle.
6314
+ */
6315
+ async function resolvesThroughSystemPseudoPath(filePath) {
6316
+ let currentPath = (0, node_path.resolve)(filePath);
6317
+ let hops = 0;
6318
+ for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
6319
+ if (isSystemPseudoPath(currentPath)) return {
6320
+ throughPseudoPath: true,
6321
+ hops
6322
+ };
6323
+ try {
6324
+ if (isSystemPseudoPath(await (0, node_fs_promises.realpath)((0, node_path.dirname)(currentPath)))) return {
6325
+ throughPseudoPath: true,
6326
+ hops
6327
+ };
6328
+ } catch {}
6329
+ let linkStats;
6330
+ try {
6331
+ linkStats = await (0, node_fs_promises.lstat)(currentPath);
6332
+ } catch {
6333
+ return {
6334
+ throughPseudoPath: false,
6335
+ hops
6336
+ };
6337
+ }
6338
+ if (!linkStats.isSymbolicLink()) break;
6339
+ let target;
6340
+ try {
6341
+ target = await (0, node_fs_promises.readlink)(currentPath);
6342
+ } catch {
6343
+ return {
6344
+ throughPseudoPath: false,
6345
+ hops
6346
+ };
6347
+ }
6348
+ currentPath = (0, node_path.isAbsolute)(target) ? target : (0, node_path.resolve)((0, node_path.dirname)(currentPath), target);
6349
+ }
6350
+ return {
6351
+ throughPseudoPath: false,
6352
+ hops
6353
+ };
6354
+ }
6355
+ const MAX_CARRIED_FILES = 1e4;
6356
+ const MAX_CARRIED_DIRECTORIES = 1e4;
6357
+ /**
6358
+ * The bounds above limit what is *carried*; this one limits what is *looked at*.
6359
+ * A directory holding nothing but a few hundred thousand links to itself carries
6360
+ * no files and occupies no depth, and would still cost a `stat` apiece.
6361
+ */
6362
+ const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
6363
+ const MAX_CARRIED_BYTES = 104857600;
6364
+ /** How many `realpath` calls the carried-file filter keeps in flight. */
6365
+ const CARRIED_REALPATH_CONCURRENCY = 32;
6366
+ /** Sort directory entries by name so a walk of the same tree is reproducible. */
6367
+ function compareByName(left, right) {
6368
+ if (left.name === right.name) return 0;
6369
+ return left.name < right.name ? -1 : 1;
6370
+ }
6371
+ /**
6372
+ * Order the routes that cross the same number of symbolic links: the one with
6373
+ * the fewest hidden segments first, so a named alias represents a shared tree
6374
+ * rather than a hidden one that a hidden-entry rule then refuses, taking the
6375
+ * named route's content with it.
6376
+ */
6377
+ function comparePendingCarriedDirs(left, right) {
6378
+ if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
6379
+ if (left.depth !== right.depth) return left.depth - right.depth;
6380
+ if (left.dirPath === right.dirPath) return 0;
6381
+ return left.dirPath < right.dirPath ? -1 : 1;
6382
+ }
6383
+ /**
6384
+ * Collect the files under a carried directory, following symbolic links but
6385
+ * visiting each real directory exactly once, by its cheapest route.
6386
+ *
6387
+ * A glob walk cannot do this safely. Two links in one directory that both point
6388
+ * back at an ancestor double the paths walked per level, and the walker follows
6389
+ * them until the kernel's ELOOP limit (~40), so the path array alone exhausts
6390
+ * the heap long before anything reads a file — a depth bound only lowers the
6391
+ * exponent, while the base is whatever number of links the tree's author chose.
6392
+ * Remembering the real directories already visited removes the multiplication
6393
+ * itself: a cycle, and an alias for a directory already walked, both stop at the
6394
+ * entry that closes them.
6395
+ *
6396
+ * Which route represents a directory then matters, because the others are
6397
+ * dropped. The walk proceeds in rounds by the number of symbolic links crossed:
6398
+ * everything reachable without crossing one, then everything one link away, and
6399
+ * so on. A real location therefore always wins over an alias for it — at any
6400
+ * nesting depth, not just among siblings, which a depth-first walk could not
6401
+ * promise — and among aliases the named one wins over a hidden one.
6402
+ *
6403
+ * A broken link is skipped: it resolves to nothing to read.
6404
+ */
6405
+ async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
6406
+ const filePaths = [];
6407
+ const visitedRealDirPaths = /* @__PURE__ */ new Set();
6408
+ const truncations = /* @__PURE__ */ new Set();
6409
+ const unreadablePaths = /* @__PURE__ */ new Set();
6410
+ const pseudoPaths = /* @__PURE__ */ new Set();
6411
+ const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
6412
+ let deferredLinkedDirs = [];
6413
+ /** Whether the walk has hit a bound that ends it rather than one branch of it. */
6414
+ let examinedEntries = 0;
6415
+ const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
6416
+ const addFile = (filePath) => {
6417
+ if (filePaths.length >= 1e4) {
6418
+ truncations.add("count");
6419
+ return;
6420
+ }
6421
+ filePaths.push(filePath);
6422
+ };
6423
+ /** Carry what a symbolic link names, or hold its directory for the next round. */
6424
+ const routeLinkedEntry = async (child, entryName) => {
6425
+ let targetStats;
6426
+ try {
6427
+ targetStats = await (0, node_fs_promises.stat)(child.dirPath);
6428
+ } catch {
6429
+ return;
6430
+ }
6431
+ if (targetStats.isFile()) {
6432
+ addFile(child.dirPath);
6433
+ return;
6434
+ }
6435
+ if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
6436
+ const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
6437
+ examinedEntries += hops;
6438
+ if (throughPseudoPath) {
6439
+ pseudoPaths.add(child.dirPath);
6440
+ return;
6441
+ }
6442
+ deferredLinkedDirs.push(child);
6443
+ };
6444
+ /** Walk one directory and everything below it that no symbolic link leads to. */
6445
+ const walkWithoutCrossingLinks = async (pending) => {
6446
+ if (isFull()) return;
6447
+ let realCurrentPath;
6448
+ try {
6449
+ realCurrentPath = await (0, node_fs_promises.realpath)(pending.dirPath);
6450
+ } catch {
6451
+ unreadablePaths.add(pending.dirPath);
6452
+ return;
6453
+ }
6454
+ if (visitedRealDirPaths.has(realCurrentPath)) return;
6455
+ if (isSystemPseudoPath(realCurrentPath)) {
6456
+ pseudoPaths.add(pending.dirPath);
6457
+ return;
6458
+ }
6459
+ if (pending.depth > 12) {
6460
+ depthStoppedRealDirPaths.add(realCurrentPath);
6461
+ return;
6462
+ }
6463
+ if (visitedRealDirPaths.size >= 1e4) {
6464
+ truncations.add("directories");
6465
+ return;
6466
+ }
6467
+ visitedRealDirPaths.add(realCurrentPath);
6468
+ let entries;
6469
+ try {
6470
+ entries = await (0, node_fs_promises.readdir)(pending.dirPath, { withFileTypes: true });
6471
+ } catch {
6472
+ unreadablePaths.add(pending.dirPath);
6473
+ return;
6474
+ }
6475
+ const realSubDirs = [];
6476
+ for (const entry of entries.toSorted(compareByName)) {
6477
+ if (isFull()) return;
6478
+ examinedEntries += 1;
6479
+ if (examinedEntries > 2e5) {
6480
+ truncations.add("entries");
6481
+ return;
6482
+ }
6483
+ if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
6484
+ const entryPath = (0, node_path.join)(pending.dirPath, entry.name);
6485
+ const child = {
6486
+ dirPath: entryPath,
6487
+ depth: pending.depth + 1,
6488
+ hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
6489
+ };
6490
+ if (entry.isFile()) {
6491
+ addFile(entryPath);
6492
+ continue;
6493
+ }
6494
+ if (entry.isDirectory()) {
6495
+ if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
6496
+ continue;
6497
+ }
6498
+ if (!entry.isSymbolicLink()) continue;
6499
+ await routeLinkedEntry(child, entry.name);
6500
+ }
6501
+ for (const realSubDir of realSubDirs) {
6502
+ if (isFull()) return;
6503
+ await walkWithoutCrossingLinks(realSubDir);
6504
+ }
6505
+ };
6506
+ let round = [{
6507
+ dirPath,
6508
+ depth: 0,
6509
+ hiddenSegments: 0
6510
+ }];
6511
+ while (round.length > 0 && !isFull()) {
6512
+ deferredLinkedDirs = [];
6513
+ for (const pending of round.toSorted(comparePendingCarriedDirs)) {
6514
+ if (isFull()) break;
6515
+ await walkWithoutCrossingLinks(pending);
6516
+ }
6517
+ round = deferredLinkedDirs;
6518
+ }
6519
+ for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
6520
+ truncations.add("depth");
6521
+ break;
6522
+ }
6523
+ return {
6524
+ filePaths: filePaths.toSorted(),
6525
+ truncations,
6526
+ unreadablePaths: [...unreadablePaths],
6527
+ pseudoPaths: [...pseudoPaths]
6528
+ };
6529
+ }
6530
+ /** Render a set of refused or noteworthy paths for one warning line. */
6531
+ function formatReportedPaths(paths) {
6532
+ const sorted = [...paths].toSorted();
6533
+ const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
6534
+ const remaining = sorted.length - 10;
6535
+ return {
6536
+ count: sorted.length,
6537
+ list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
6538
+ };
6539
+ }
6540
+ /** "entry" or "entries", so the warnings below read as sentences. */
6541
+ function entryWord(count) {
6542
+ return count === 1 ? "entry" : "entries";
6543
+ }
6544
+ /** "resolves" or "resolve", to agree with the entry count it follows. */
6545
+ function resolveWord(count) {
6546
+ return count === 1 ? "resolves" : "resolve";
6547
+ }
6548
+ var AiDir = class AiDir {
5474
6549
  /**
5475
6550
  * @example "."
5476
6551
  */
@@ -5520,8 +6595,7 @@ var AiDir = class {
5520
6595
  const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.dirName);
5521
6596
  const resolvedFull = (0, node_path.resolve)(fullPath);
5522
6597
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
5523
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
5524
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
6598
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
5525
6599
  return fullPath;
5526
6600
  }
5527
6601
  getMainFile() {
@@ -5547,9 +6621,227 @@ var AiDir = class {
5547
6621
  };
5548
6622
  }
5549
6623
  /**
6624
+ * A nested repository inside a carried directory is the one exclusion worth
6625
+ * reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
6626
+ * points at is simply not reproduced on generate. Only the top level is
6627
+ * checked, which is where a submodule or a stray `git init` puts it, so the
6628
+ * check costs one stat per directory rather than a second walk. `fileExists`
6629
+ * is a bare `stat`, so it answers for a submodule pointer file and for a real
6630
+ * `.git` directory alike.
6631
+ */
6632
+ static async warnOnNestedGitDirectory(dirPath) {
6633
+ const gitEntryPath = (0, node_path.join)(dirPath, ".git");
6634
+ if (await fileExists(gitEntryPath)) warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(gitEntryPath))} with its directory: a nested repository is excluded, so the files it tracks are copied but its history is not.`);
6635
+ }
6636
+ /** Whether any segment of a relative path is dot-prefixed. */
6637
+ static hasHiddenSegment(relativePath) {
6638
+ return splitPathSegments(relativePath).some(isHiddenPathSegment);
6639
+ }
6640
+ /**
6641
+ * Whether the entry a path ends at is itself hidden, its ancestors aside.
6642
+ * A shared skill tree usually lives under a dot-directory, so an ancestor
6643
+ * says nothing about the file; its own name is what was chosen for it.
6644
+ */
6645
+ static hasHiddenName(relativePath) {
6646
+ return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
6647
+ }
6648
+ /**
6649
+ * Drop the entries a skill directory must not carry.
6650
+ *
6651
+ * Three rules. `classifyNeverCarried` comes first, evaluated against the
6652
+ * resolved real path as well as the literal one: names that are never skill
6653
+ * content stay out however they are reached, so renaming a symbolic link
6654
+ * does not turn `~/.aws` into content a skill carries. Next, a real path
6655
+ * inside a kernel pseudo-filesystem is refused outright — that is process
6656
+ * state, not a file.
6657
+ *
6658
+ * The third concerns hidden entries a symbolic link reaches outside the
6659
+ * directory. Following symlinks out of a source tree is deliberate and
6660
+ * documented — it is how a shared skill is referenced from several projects
6661
+ * without being duplicated (issue #1707), and the trust boundary is the tree
6662
+ * you point Rulesync at. Carrying hidden entries changes what that costs,
6663
+ * though: one ordinary-looking link to a home directory would pull in every
6664
+ * dotfile under it, and those are the entries with credential value. What
6665
+ * decides is the name in the skill directory, not what the link resolves
6666
+ * through: a named file keeps its documented behavior even when the target
6667
+ * sits under a dot-directory such as `~/.dotfiles`, because somebody chose
6668
+ * that name. What the link resolves *to* still counts at the end of the path,
6669
+ * though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
6670
+ * file nobody named for a skill — so a hidden final segment is refused on
6671
+ * either side. Reaching outside is reported either way, since content from
6672
+ * outside the tree is about to be copied into every enabled tool root.
6673
+ *
6674
+ * A path that cannot be resolved is kept: `realpath` fails on a broken link
6675
+ * or a race, and neither is a reason to silently drop a file.
6676
+ */
6677
+ /** Report what a carried directory left out, once both walks have had their say. */
6678
+ static warnOnRefusedCarriedFiles(dirPath, carried) {
6679
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6680
+ if (carried.refusedCredentials.size > 0) {
6681
+ const { count, list } = formatReportedPaths(carried.refusedCredentials);
6682
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} named as a credential store: ${list}. A skill must not ship secrets; read them from the environment instead.`);
6683
+ }
6684
+ if (carried.refusedPseudoPaths.size > 0) {
6685
+ const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
6686
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a system pseudo-filesystem: ${list}. Those read back process state, not skill content.`);
6687
+ }
6688
+ if (carried.refusedNoiseAliases.size > 0) {
6689
+ const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
6690
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a directory a skill never carries: ${list}. A nested repository, or a build or cache tree, is the usual target.`);
6691
+ }
6692
+ if (carried.refusedEscapedHidden.size > 0) {
6693
+ const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
6694
+ warnOnceWithFallback(void 0, `Not carrying ${count} hidden ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Copy them into the directory if the skill really needs them.`);
6695
+ }
6696
+ if (carried.carriedFromOutside.size > 0) {
6697
+ const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
6698
+ const realFilePath = carried.realFilePathByPath.get(filePath);
6699
+ return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
6700
+ }));
6701
+ warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
6702
+ }
6703
+ }
6704
+ static async filterCarriedFiles(dirPath, filePaths) {
6705
+ let realDirPath;
6706
+ try {
6707
+ realDirPath = await (0, node_fs_promises.realpath)(dirPath);
6708
+ } catch {
6709
+ realDirPath = (0, node_path.resolve)(dirPath);
6710
+ }
6711
+ const refusedCredentials = /* @__PURE__ */ new Set();
6712
+ const refusedPseudoPaths = /* @__PURE__ */ new Set();
6713
+ const refusedEscapedHidden = /* @__PURE__ */ new Set();
6714
+ const refusedNoiseAliases = /* @__PURE__ */ new Set();
6715
+ const carriedFromOutside = /* @__PURE__ */ new Set();
6716
+ const realFilePathByPath = /* @__PURE__ */ new Map();
6717
+ const verdicts = await mapWithConcurrency({
6718
+ items: filePaths,
6719
+ limit: CARRIED_REALPATH_CONCURRENCY,
6720
+ mapper: async (filePath) => {
6721
+ const literalPath = (0, node_path.relative)(dirPath, filePath);
6722
+ const literalReason = classifyNeverCarried(literalPath);
6723
+ if (literalReason !== void 0) {
6724
+ if (literalReason === "credential") refusedCredentials.add(filePath);
6725
+ return false;
6726
+ }
6727
+ let realFilePath;
6728
+ try {
6729
+ realFilePath = await (0, node_fs_promises.realpath)(filePath);
6730
+ } catch {
6731
+ return true;
6732
+ }
6733
+ realFilePathByPath.set(filePath, realFilePath);
6734
+ if (isSystemPseudoPath(realFilePath)) {
6735
+ refusedPseudoPaths.add(filePath);
6736
+ return false;
6737
+ }
6738
+ const realPath = (0, node_path.relative)(realDirPath, realFilePath);
6739
+ const realReason = classifyNeverCarried(realPath);
6740
+ if (realReason !== void 0) {
6741
+ if (realReason === "credential") refusedCredentials.add(filePath);
6742
+ else refusedNoiseAliases.add(filePath);
6743
+ return false;
6744
+ }
6745
+ if (!pathEscapesRoot(realPath)) return true;
6746
+ if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
6747
+ refusedPseudoPaths.add(filePath);
6748
+ return false;
6749
+ }
6750
+ if (endsWithNeverCarriedSuffix(realFilePath)) {
6751
+ refusedCredentials.add(filePath);
6752
+ return false;
6753
+ }
6754
+ if (escapesIntoCredentialDir({
6755
+ realDirPath,
6756
+ realFilePath
6757
+ })) {
6758
+ refusedCredentials.add(filePath);
6759
+ return false;
6760
+ }
6761
+ if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
6762
+ refusedEscapedHidden.add(filePath);
6763
+ return false;
6764
+ }
6765
+ carriedFromOutside.add(filePath);
6766
+ return true;
6767
+ }
6768
+ });
6769
+ return {
6770
+ filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
6771
+ realFilePathByPath,
6772
+ refusedCredentials,
6773
+ refusedPseudoPaths,
6774
+ refusedEscapedHidden,
6775
+ refusedNoiseAliases,
6776
+ carriedFromOutside
6777
+ };
6778
+ }
6779
+ /**
6780
+ * Report what the walk had to leave behind, so a skill that silently lost
6781
+ * files says so rather than generating a directory that is quietly short.
6782
+ */
6783
+ static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
6784
+ 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.`);
6785
+ 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.`);
6786
+ 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.`);
6787
+ if (truncations.has("entries")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} that come after the first ${MAX_CARRIED_ENTRIES_EXAMINED} looked at: a directory is walked over at most that many entries. A tree of symbolic links that lead back into it is the usual cause.`);
6788
+ if (unreadablePaths.length > 0) {
6789
+ const { count, list } = formatReportedPaths(unreadablePaths);
6790
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that could not be read: ${list}. A permission the current user does not hold is the usual cause.`);
6791
+ }
6792
+ }
6793
+ /**
6794
+ * Walk the directory once more with the hidden routes pruned, and take the
6795
+ * files the first walk had to leave behind because the route that reached
6796
+ * them ran through a hidden directory. This can only add files, and only ones
6797
+ * a fully named route reaches.
6798
+ */
6799
+ static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
6800
+ const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
6801
+ const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
6802
+ for (const truncation of named.truncations) walk.truncations.add(truncation);
6803
+ for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
6804
+ for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6805
+ for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
6806
+ for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
6807
+ for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
6808
+ for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
6809
+ for (const filePath of recovered.filePaths) {
6810
+ const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
6811
+ if (carriedRealPaths.has(realFilePath)) continue;
6812
+ if (carriedPaths.length >= 1e4) {
6813
+ walk.truncations.add("count");
6814
+ break;
6815
+ }
6816
+ carriedRealPaths.add(realFilePath);
6817
+ carriedPaths.push(filePath);
6818
+ carried.realFilePathByPath.set(filePath, realFilePath);
6819
+ if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
6820
+ }
6821
+ for (const filePath of carried.refusedEscapedHidden) {
6822
+ const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
6823
+ if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
6824
+ }
6825
+ }
6826
+ /**
5550
6827
  * Recursively collects all files from a directory, excluding the specified main file.
5551
6828
  * This is a common utility for loading additional files alongside the main file.
5552
6829
  *
6830
+ * Hidden entries are included. The directories this walks are skill trees,
6831
+ * whose specification says a skill directory "may contain any files and
6832
+ * directories beyond the required `SKILL.md`" — a `.env.example` beside the
6833
+ * scripts that read it is content, not noise, and dropping it silently on
6834
+ * both import and generate loses part of the skill. What is left out is the
6835
+ * set of entries that are never skill content — a nested repository's `.git`,
6836
+ * the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
6837
+ * as decided by `classifyNeverCarried`. Whole directories from that set are
6838
+ * pruned during the walk too, so it never descends into them at all.
6839
+ *
6840
+ * The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
6841
+ * tree may contain symbolic links that somebody else chose.
6842
+ *
6843
+ * @see https://agentskills.io/specification
6844
+ *
5553
6845
  * @param outputRoot - The base directory path
5554
6846
  * @param relativeDirPath - The relative path to the directory containing the skill
5555
6847
  * @param dirName - The name of the directory
@@ -5558,14 +6850,57 @@ var AiDir = class {
5558
6850
  */
5559
6851
  static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
5560
6852
  const dirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
5561
- const filteredPaths = (await findFilesByGlobs((0, node_path.join)(dirPath, "**", "*"), { type: "file" })).filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
5562
- return await Promise.all(filteredPaths.map(async (filePath) => {
5563
- const fileBuffer = await readFileBuffer(filePath);
5564
- return {
5565
- relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
5566
- fileBuffer
5567
- };
5568
- }));
6853
+ const walk = await walkCarriedFiles(dirPath);
6854
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6855
+ await AiDir.warnOnNestedGitDirectory(dirPath);
6856
+ const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
6857
+ const carriedPaths = [...carried.filePaths];
6858
+ const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
6859
+ if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
6860
+ dirPath,
6861
+ carried,
6862
+ carriedPaths,
6863
+ carriedRealPaths,
6864
+ walk
6865
+ });
6866
+ for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6867
+ AiDir.warnOnCarriedWalkLimits({
6868
+ reportedDirPath,
6869
+ truncations: walk.truncations,
6870
+ unreadablePaths: walk.unreadablePaths
6871
+ });
6872
+ AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
6873
+ const filteredPaths = carriedPaths.toSorted().filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
6874
+ const files = [];
6875
+ let carriedBytes = 0;
6876
+ for (const [index, filePath] of filteredPaths.entries()) {
6877
+ const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
6878
+ let fileHandle;
6879
+ try {
6880
+ fileHandle = await (0, node_fs_promises.open)(classifiedPath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
6881
+ } catch (error) {
6882
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6883
+ continue;
6884
+ }
6885
+ try {
6886
+ const fileSize = (await fileHandle.stat()).size;
6887
+ if (carriedBytes + fileSize > 104857600) {
6888
+ 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.`);
6889
+ break;
6890
+ }
6891
+ const fileBuffer = await fileHandle.readFile();
6892
+ carriedBytes += fileBuffer.byteLength;
6893
+ files.push({
6894
+ relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
6895
+ fileBuffer
6896
+ });
6897
+ } catch (error) {
6898
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6899
+ } finally {
6900
+ await fileHandle.close();
6901
+ }
6902
+ }
6903
+ return files;
5569
6904
  }
5570
6905
  };
5571
6906
  const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
@@ -5705,7 +7040,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5705
7040
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5706
7041
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5707
7042
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
5708
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
7043
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
7044
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
7045
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
7046
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
7047
+ version: zod_mini.z.optional(zod_mini.z.unknown())
5709
7048
  })),
5710
7049
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
5711
7050
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -5788,7 +7127,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
5788
7127
  const skillDirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
5789
7128
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
5790
7129
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
5791
- const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
7130
+ const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
5792
7131
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5793
7132
  const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
5794
7133
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
@@ -5881,8 +7220,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5881
7220
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5882
7221
  };
5883
7222
  }
5884
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5885
- const filePath = (0, node_path.join)(outputRoot, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath);
7223
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
7224
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
7225
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
5886
7226
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5887
7227
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5888
7228
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5890,7 +7230,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5890
7230
  const filename = (0, node_path.basename)(relativeFilePath);
5891
7231
  return new RulesyncSubagent({
5892
7232
  outputRoot,
5893
- relativeDirPath: this.getSettablePaths().relativeDirPath,
7233
+ relativeDirPath: dirPath,
5894
7234
  relativeFilePath: filename,
5895
7235
  frontmatter: result.data,
5896
7236
  body: content.trim()
@@ -5900,16 +7240,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5900
7240
  //#endregion
5901
7241
  //#region src/features/skills/skills-utils.ts
5902
7242
  /**
5903
- * Returns the set of local skill directory names (excluding `.curated`).
7243
+ * Returns the set of local skill directory names (excluding `.curated`)
7244
+ * from a rulesync source tree (e.g. `/repo/.rulesync` or
7245
+ * `/repo/.rulesync.local`).
5904
7246
  */
5905
- async function getLocalSkillDirNames(outputRoot) {
5906
- const skillsDir = (0, node_path.join)(outputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
7247
+ async function getLocalSkillDirNames(sourceTree) {
7248
+ const skillsDir = (0, node_path.join)(sourceTree, SKILLS_FEATURE_SUBDIR);
5907
7249
  const names = /* @__PURE__ */ new Set();
5908
7250
  if (!await directoryExists(skillsDir)) return names;
5909
7251
  const dirPaths = await findFilesByGlobs((0, node_path.join)(skillsDir, "*"), { type: "dir" });
5910
7252
  for (const dirPath of dirPaths) {
5911
7253
  const name = (0, node_path.basename)(dirPath);
5912
- if (name === (0, node_path.basename)(RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH)) continue;
7254
+ if (name === (0, node_path.basename)(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
5913
7255
  names.add(name);
5914
7256
  }
5915
7257
  return names;
@@ -6225,12 +7567,25 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6225
7567
  //#region src/types/feature-processor.ts
6226
7568
  var FeatureProcessor = class {
6227
7569
  outputRoot;
6228
- inputRoot;
7570
+ /**
7571
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
7572
+ * is a source tree itself — the directory that directly contains feature
7573
+ * subdirectories (`rules/`, `commands/`, …) and single-file features
7574
+ * (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
7575
+ * per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
7576
+ *
7577
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
7578
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
7579
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
7580
+ * reaches a processor — every internal consumer only ever sees the
7581
+ * plural form, with the source tree already resolved.
7582
+ */
7583
+ inputRoots;
6229
7584
  dryRun;
6230
7585
  logger;
6231
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
7586
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6232
7587
  this.outputRoot = outputRoot;
6233
- this.inputRoot = inputRoot;
7588
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
6234
7589
  this.dryRun = dryRun;
6235
7590
  this.logger = logger;
6236
7591
  }
@@ -6297,6 +7652,198 @@ var FeatureProcessor = class {
6297
7652
  return orphanFiles.length;
6298
7653
  }
6299
7654
  };
7655
+ /**
7656
+ * Messages already reported for a given logger.
7657
+ *
7658
+ * One `generate` run constructs a single-file processor per tool target and
7659
+ * per output root — more than twenty times for `--targets "*"` — and each one
7660
+ * re-resolves the same roots. Keying on the logger — created once per run, and
7661
+ * once per test — keeps the shadowing warning to a single line instead of
7662
+ * repeating it for every target. `--watch` reuses one logger across runs, so
7663
+ * {@link resetRootShadowingWarnings} clears the set at the start of each one.
7664
+ */
7665
+ const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
7666
+ /**
7667
+ * Forget which shadowing warnings have already been reported.
7668
+ *
7669
+ * `generate` calls this once per run. Without it, `--watch` reuses one logger
7670
+ * for the whole session, so the warning would be printed on the first
7671
+ * generation and never again — the opposite of why it is a warning, since
7672
+ * `--watch` is exactly when an overlay is most likely to be added or edited.
7673
+ */
7674
+ function resetRootShadowingWarnings({ logger }) {
7675
+ warnedRootShadowingByLogger.delete(logger);
7676
+ }
7677
+ /**
7678
+ * Return the last input root that contains any of the given `relativePaths`,
7679
+ * or `undefined` when none of the roots has any of them. Used by single-file
7680
+ * features (hooks, permissions, ignore) to implement the "later root wins
7681
+ * the whole file" merge policy without materializing the file contents.
7682
+ *
7683
+ * `relativePaths` accepts a small list so features that historically read
7684
+ * either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
7685
+ * plus `.rulesync/mcp.json`) can preserve that resolution order per root.
7686
+ * A root counts as "having" the file as long as at least one candidate path
7687
+ * is present.
7688
+ */
7689
+ async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
7690
+ let winner;
7691
+ const rootsWithFile = [];
7692
+ for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists((0, node_path.join)(root, relativePath))) {
7693
+ winner = root;
7694
+ rootsWithFile.push(root);
7695
+ break;
7696
+ }
7697
+ if (rootsWithFile.length > 1 && winner !== void 0) {
7698
+ const shadowed = rootsWithFile.slice(0, -1);
7699
+ const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
7700
+ let warnedMessages = warnedRootShadowingByLogger.get(logger);
7701
+ if (warnedMessages === void 0) {
7702
+ warnedMessages = /* @__PURE__ */ new Set();
7703
+ warnedRootShadowingByLogger.set(logger, warnedMessages);
7704
+ }
7705
+ if (!warnedMessages.has(message)) {
7706
+ warnedMessages.add(message);
7707
+ logger.warn(message);
7708
+ }
7709
+ }
7710
+ return winner;
7711
+ }
7712
+ /**
7713
+ * Merge per-root result lists into a single ordered list, keeping the
7714
+ * later root's entry when two roots produced an item with the same
7715
+ * identity. Identity is intentionally provided by the caller so
7716
+ * per-feature nuances (case-insensitive filesystems, directory names for
7717
+ * skills, server names for MCP) live next to the feature that owns them.
7718
+ *
7719
+ * The returned list preserves the FIRST appearance order of each identity —
7720
+ * items in the earliest root keep their position, but their content is
7721
+ * replaced by the last root that provided the same identity. This matches
7722
+ * the "overlay" mental model: an overlay changes content, not order.
7723
+ */
7724
+ function mergeByIdentity({ perRoot, identity }) {
7725
+ const order = [];
7726
+ const winnerByKey = /* @__PURE__ */ new Map();
7727
+ for (const rootItems of perRoot) for (const item of rootItems) {
7728
+ const key = identity(item);
7729
+ if (!winnerByKey.has(key)) order.push(key);
7730
+ winnerByKey.set(key, item);
7731
+ }
7732
+ return order.map((key) => winnerByKey.get(key));
7733
+ }
7734
+ /**
7735
+ * The key two spellings share when a case-insensitive filesystem would give
7736
+ * them one file. `toLowerCase()` is locale-independent (unlike
7737
+ * `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
7738
+ * locale), and the NFC pass folds the composed and decomposed spellings of an
7739
+ * accented name — which macOS also resolves to a single directory —
7740
+ * onto each other.
7741
+ *
7742
+ * This is simple lowercasing rather than full Unicode case folding, so it is
7743
+ * deliberately narrower than what a filesystem considers one file: a Greek
7744
+ * final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
7745
+ * trailing dot is stripped all still produce distinct keys. Those pairs keep
7746
+ * the pre-existing behavior (both are imported, and the later one wins on the
7747
+ * filesystem); folding them here would instead drop names that a
7748
+ * case-sensitive filesystem keeps genuinely apart.
7749
+ */
7750
+ function caseFoldIdentity(identity) {
7751
+ return identity.normalize("NFC").toLowerCase();
7752
+ }
7753
+ /**
7754
+ * Group spellings by their case-folded identity, keeping every original
7755
+ * spelling. On a case-sensitive filesystem one identity can cover several
7756
+ * spellings at once, and the caller needs them all to describe a collision
7757
+ * accurately.
7758
+ */
7759
+ function groupSpellingsByCaseFoldedIdentity(spellings) {
7760
+ const grouped = /* @__PURE__ */ new Map();
7761
+ for (const spelling of spellings) {
7762
+ const identity = caseFoldIdentity(spelling);
7763
+ const existing = grouped.get(identity);
7764
+ if (existing === void 0) grouped.set(identity, [spelling]);
7765
+ else existing.push(spelling);
7766
+ }
7767
+ return grouped;
7768
+ }
7769
+ /**
7770
+ * Build the warning emitted when a `.curated/` entry and a local entry in the
7771
+ * same tree differ only in case.
7772
+ *
7773
+ * `.curated/` is expanded from a declarative source (an external Git repository
7774
+ * or npm package), so its names are untrusted input; both sides are stripped of
7775
+ * control characters before they reach the terminal.
7776
+ *
7777
+ * The winning local spelling is the LAST one, matching the precedence
7778
+ * {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
7779
+ * that folds onto the same identity is listed too, so the message never names a
7780
+ * spelling that loses.
7781
+ */
7782
+ function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
7783
+ const winner = localSpellings[localSpellings.length - 1] ?? "";
7784
+ const shadowed = localSpellings.slice(0, -1);
7785
+ const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
7786
+ return `Case-insensitive ${artifactKind} collision under ${stripControlCharacters(treeDirPath)}: curated '${stripControlCharacters(curatedSpelling)}' and local '${stripControlCharacters(winner)}' resolve to the same identity. The local ${entryNoun} wins and the curated ${entryNoun} is skipped.` + shadowedSuffix;
7787
+ }
7788
+ /**
7789
+ * Merge artifacts whose filenames are case-insensitive identities, warning
7790
+ * when distinct spellings collapse to the same key. Exact-name overlays are
7791
+ * intentional and remain quiet; only case-only ambiguity is diagnosed.
7792
+ *
7793
+ * Precedence here is the opposite of {@link ClaimedIdentities}: this merges
7794
+ * overlay roots, where the LAST entry wins, while the tool-side loaders keep
7795
+ * the FIRST root to claim a name.
7796
+ */
7797
+ function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
7798
+ const spellingByKey = /* @__PURE__ */ new Map();
7799
+ const warnedKeys = /* @__PURE__ */ new Set();
7800
+ return mergeByIdentity({
7801
+ perRoot,
7802
+ identity: (item) => {
7803
+ const spelling = identity(item);
7804
+ const key = caseFoldIdentity(spelling);
7805
+ const previousSpelling = spellingByKey.get(key);
7806
+ if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
7807
+ logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
7808
+ warnedKeys.add(key);
7809
+ }
7810
+ if (previousSpelling === void 0) spellingByKey.set(key, spelling);
7811
+ return key;
7812
+ }
7813
+ });
7814
+ }
7815
+ /**
7816
+ * Tracks the import identities already claimed while scanning, folding case
7817
+ * through {@link caseFoldIdentity}.
7818
+ *
7819
+ * The tool-side loaders scan several roots in precedence order and keep the
7820
+ * first spelling of each identity. Comparing those identities exactly lets
7821
+ * `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
7822
+ * since macOS and Windows resolve the two written-back directories to a
7823
+ * single one, the shared Agent Skills copy lands last and overwrites the
7824
+ * tool-specific one — inverting the precedence the roots were ordered by.
7825
+ *
7826
+ * The FIRST claimer wins, which is the opposite of the overlay precedence in
7827
+ * {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
7828
+ * order, so the earliest one to name a skill is the one that should keep it.
7829
+ */
7830
+ var ClaimedIdentities = class {
7831
+ claimByKey = /* @__PURE__ */ new Map();
7832
+ /**
7833
+ * Claim `identity` on behalf of `source`. Returns `null` when nothing held
7834
+ * it yet, or the standing claim when something did.
7835
+ */
7836
+ claim({ identity, source }) {
7837
+ const key = caseFoldIdentity(identity);
7838
+ const claimed = this.claimByKey.get(key);
7839
+ if (claimed !== void 0) return claimed;
7840
+ this.claimByKey.set(key, {
7841
+ spelling: identity,
7842
+ source
7843
+ });
7844
+ return null;
7845
+ }
7846
+ };
6300
7847
  //#endregion
6301
7848
  //#region src/constants/amp-paths.ts
6302
7849
  const AMP_DIR = ".amp";
@@ -8619,10 +10166,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8619
10166
  toolTarget;
8620
10167
  global;
8621
10168
  getFactory;
8622
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
10169
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
8623
10170
  super({
8624
10171
  outputRoot,
8625
- inputRoot,
10172
+ inputRoots,
8626
10173
  dryRun,
8627
10174
  logger
8628
10175
  });
@@ -8659,11 +10206,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8659
10206
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8660
10207
  }
8661
10208
  /**
8662
- * Implementation of abstract method from Processor
8663
- * Load and parse rulesync check files from .rulesync/checks/ directory
10209
+ * Load check files from a single source-tree's `checks/` subtree.
10210
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
10211
+ * `/repo/.rulesync.local`).
8664
10212
  */
8665
- async loadRulesyncFiles() {
8666
- const checksDir = (0, node_path.join)(this.inputRoot, RulesyncCheck.getSettablePaths().relativeDirPath);
10213
+ async loadRulesyncFilesForRoot(sourceTree) {
10214
+ const treeParent = (0, node_path.dirname)(sourceTree);
10215
+ const treeName = (0, node_path.basename)(sourceTree);
10216
+ const treeChecksDirPath = (0, node_path.join)(treeName, CHECKS_FEATURE_SUBDIR);
10217
+ const checksDir = (0, node_path.join)(sourceTree, CHECKS_FEATURE_SUBDIR);
8667
10218
  if (!await directoryExists(checksDir)) {
8668
10219
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8669
10220
  return [];
@@ -8679,7 +10230,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8679
10230
  const filepath = (0, node_path.join)(checksDir, mdFile);
8680
10231
  try {
8681
10232
  const rulesyncCheck = await RulesyncCheck.fromFile({
8682
- outputRoot: this.inputRoot,
10233
+ outputRoot: treeParent,
10234
+ relativeDirPath: treeChecksDirPath,
8683
10235
  relativeFilePath: mdFile,
8684
10236
  validate: true
8685
10237
  });
@@ -8690,6 +10242,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8690
10242
  continue;
8691
10243
  }
8692
10244
  }
10245
+ return rulesyncChecks;
10246
+ }
10247
+ /**
10248
+ * Implementation of abstract method from Processor
10249
+ * Load and parse rulesync check files from every configured input root's
10250
+ * `.rulesync/checks/` directory, merging by relative file path so a check
10251
+ * from a later root replaces the earlier root's copy.
10252
+ */
10253
+ async loadRulesyncFiles() {
10254
+ const rulesyncChecks = mergeByCaseInsensitiveIdentity({
10255
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
10256
+ identity: (check) => check.getRelativeFilePath(),
10257
+ artifactName: "check",
10258
+ logger: this.logger
10259
+ });
8693
10260
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8694
10261
  return rulesyncChecks;
8695
10262
  }
@@ -10168,18 +11735,23 @@ function commandSlug(relativeFilePath) {
10168
11735
  return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10169
11736
  }
10170
11737
  /**
10171
- * Whether a rulesync command exists whose slug matches `dirName`.
11738
+ * Whether a rulesync command exists whose slug matches `dirName` in any of
11739
+ * the configured input roots.
11740
+ *
11741
+ * `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
11742
+ * `/repo/.rulesync.local`), so commands live directly under
11743
+ * `<sourceTree>/commands/`.
10172
11744
  *
10173
11745
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10174
11746
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10175
11747
  * current command slug is owned by the commands feature, so the skills
10176
11748
  * feature must neither import it as a skill nor delete it as an orphan
10177
- * skill. Once the command is removed from `.rulesync/commands/`, the
10178
- * directory stops matching and the skills feature cleans it up as a regular
10179
- * orphan.
11749
+ * skill. Once the command is removed from every source tree's `commands/`
11750
+ * directory, the directory stops matching and the skills feature cleans
11751
+ * it up as a regular orphan.
10180
11752
  */
10181
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10182
- return (await findFilesByGlobs((0, node_path.join)(inputRoot, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"))).some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
11753
+ async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
11754
+ 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);
10183
11755
  }
10184
11756
  //#endregion
10185
11757
  //#region src/features/commands/devin-command.ts
@@ -10869,11 +12441,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10869
12441
  * output is lost — so this warns rather than failing the run the way the
10870
12442
  * Hermes check does, where the two surfaces really do write the same path.
10871
12443
  */
10872
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
12444
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10873
12445
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => (0, node_path.basename)(command.getRelativeFilePath(), ".md")));
10874
12446
  if (commandNames.size === 0) return;
10875
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
10876
- const shadowed = (await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"))).map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
12447
+ const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
10877
12448
  if (shadowed.length > 0) logger.warn(`Grok CLI resolves skills before commands, so these skills shadow the same-named commands, which will never be reachable: ${[...new Set(shadowed)].toSorted().join(", ")}. Rename either side to make both invocable.`);
10878
12449
  }
10879
12450
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -10919,6 +12490,26 @@ function toolSkillSearchRoots(paths) {
10919
12490
  * https://agentskills.io/client-implementation/adding-skills-support
10920
12491
  */
10921
12492
  const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
12493
+ /**
12494
+ * The one spec violation both sides of the conversion report. Generation warns
12495
+ * about it before writing the file; import warns about it because a conformant
12496
+ * client would skip the skill entirely, and a user who never sees that has no
12497
+ * reason to fix it.
12498
+ */
12499
+ const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
12500
+ /**
12501
+ * Report a skill read from disk whose `description` is empty. This lives on the
12502
+ * read rather than on any one tool class because several targets share one
12503
+ * `.agents/skills` tree: which of them reports the skill must not depend on
12504
+ * which target the user happened to enable. Every loader calls it — the two
12505
+ * directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
12506
+ * and the flat-file one — so the form a tool stores its skills in does not
12507
+ * decide whether the problem is reported either.
12508
+ */
12509
+ function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
12510
+ if (typeof description !== "string" || description.length > 0) return;
12511
+ warnOnceWithFallback(void 0, `${stripControlCharacters(toPosixPath(skillFilePath))}: ${EMPTY_SKILL_DESCRIPTION_VIOLATION}. Rulesync imports it anyway so the content is not lost; fill it in before relying on this skill.`);
12512
+ }
10922
12513
  function isAgentSkillsInteropRoot(relativeDirPath) {
10923
12514
  return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
10924
12515
  }
@@ -11031,7 +12622,11 @@ var ToolSkill = class extends AiDir {
11031
12622
  const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
11032
12623
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
11033
12624
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
11034
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
12625
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
12626
+ warnOnEmptyLoadedDescription({
12627
+ skillFilePath,
12628
+ description: frontmatter.description
12629
+ });
11035
12630
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
11036
12631
  return {
11037
12632
  outputRoot,
@@ -11156,6 +12751,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
11156
12751
  ...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
11157
12752
  };
11158
12753
  }
12754
+ /** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
12755
+ function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
12756
+ return (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12757
+ }
11159
12758
  /**
11160
12759
  * Collect the normative violations the Agent Skills spec defines for a skill
11161
12760
  * about to be written. These are reported as warnings rather than errors:
@@ -11182,7 +12781,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
11182
12781
  if (!NAME_PATTERN.test(name)) violations.push(`\`name\` "${name}" must contain only lowercase letters, digits and single hyphens, with no leading, trailing or consecutive hyphens`);
11183
12782
  if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
11184
12783
  }
11185
- if (description.length === 0) violations.push("`description` is required and must not be empty; conformant clients skip a skill without one");
12784
+ if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
11186
12785
  else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
11187
12786
  const { compatibility } = frontmatter;
11188
12787
  if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
@@ -11301,12 +12900,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
11301
12900
  * directory rather than a same-named project path.
11302
12901
  */
11303
12902
  static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
11304
- const skillPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12903
+ const skillPath = agentSkillFilePath({
12904
+ outputRoot,
12905
+ relativeDirPath,
12906
+ dirName
12907
+ });
11305
12908
  for (const violation of collectAgentSkillViolations({
11306
12909
  frontmatter,
11307
12910
  dirName,
11308
12911
  sourceAllowedTools
11309
- })) warnWithFallback(logger, `${skillPath}: ${violation}`);
12912
+ })) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
11310
12913
  }
11311
12914
  static isTargetedByRulesyncSkill(rulesyncSkill) {
11312
12915
  const targets = rulesyncSkill.getFrontmatter().targets;
@@ -11645,7 +13248,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11645
13248
  static getExtraSharedWritePaths() {
11646
13249
  return getHermesagentSharedConfigWritePaths();
11647
13250
  }
11648
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
13251
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11649
13252
  const commandSlugs = /* @__PURE__ */ new Set();
11650
13253
  const commandOrigins = /* @__PURE__ */ new Map();
11651
13254
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11657,13 +13260,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11657
13260
  commandOrigins.set(slug, origin);
11658
13261
  commandSlugs.add(slug);
11659
13262
  }
11660
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
11661
- const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
11662
- const collisions = (await Promise.all(skillFiles.map((path) => RulesyncSkill.fromDir({
11663
- outputRoot: inputRoot,
11664
- relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
11665
- dirName: toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(path)))
11666
- })))).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
13263
+ const skillsByName = /* @__PURE__ */ new Map();
13264
+ for (const rootPath of inputRoots) {
13265
+ const skillsRoot = (0, node_path.join)(rootPath, SKILLS_FEATURE_SUBDIR);
13266
+ const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
13267
+ const loaded = await Promise.all(skillFiles.map(async (filePath) => {
13268
+ const dirName = toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(filePath)));
13269
+ return {
13270
+ rulesyncSkill: await RulesyncSkill.fromDir({
13271
+ outputRoot: rootPath,
13272
+ relativeDirPath: SKILLS_FEATURE_SUBDIR,
13273
+ dirName
13274
+ }),
13275
+ dirName,
13276
+ rootPath
13277
+ };
13278
+ }));
13279
+ for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
13280
+ }
13281
+ const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
11667
13282
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11668
13283
  }
11669
13284
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11796,6 +13411,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11796
13411
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11797
13412
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11798
13413
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
13414
+ const JUNIE_RULES_DIR_NAME = "rules";
13415
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11799
13416
  //#endregion
11800
13417
  //#region src/features/commands/junie-command.ts
11801
13418
  const JunieCommandFrontmatterSchema = zod_mini.z.looseObject({ description: zod_mini.z.optional(zod_mini.z.string()) });
@@ -13756,10 +15373,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13756
15373
  global;
13757
15374
  getFactory;
13758
15375
  flattenedCommandNaming;
13759
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
15376
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
13760
15377
  super({
13761
15378
  outputRoot,
13762
- inputRoot,
15379
+ inputRoots,
13763
15380
  dryRun,
13764
15381
  logger
13765
15382
  });
@@ -13774,7 +15391,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13774
15391
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13775
15392
  const factory = this.getFactory(this.toolTarget);
13776
15393
  await factory.class.validateRulesyncCommands?.({
13777
- inputRoot: this.inputRoot,
15394
+ inputRoots: this.inputRoots,
13778
15395
  rulesyncCommands,
13779
15396
  logger: this.logger
13780
15397
  });
@@ -13826,16 +15443,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13826
15443
  return rel;
13827
15444
  }
13828
15445
  /**
13829
- * Implementation of abstract method from FeatureProcessor
13830
- * Load and parse rulesync command files from .rulesync/commands/ directory
15446
+ * Load rulesync command files from a single source-tree's `commands/`
15447
+ * subtree. `sourceTree` is the source tree itself (e.g.
15448
+ * `/repo/.rulesync` or `/repo/.rulesync.local`).
13831
15449
  */
13832
- async loadRulesyncFiles() {
13833
- const basePath = (0, node_path.join)(this.inputRoot, RulesyncCommand.getSettablePaths().relativeDirPath);
15450
+ async loadRulesyncFilesForRoot(sourceTree) {
15451
+ const treeParent = (0, node_path.dirname)(sourceTree);
15452
+ const treeName = (0, node_path.basename)(sourceTree);
15453
+ const treeCommandsDirPath = (0, node_path.join)(treeName, COMMANDS_FEATURE_SUBDIR);
15454
+ const basePath = (0, node_path.join)(sourceTree, COMMANDS_FEATURE_SUBDIR);
13834
15455
  const rulesyncCommandPaths = await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md"));
13835
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13836
- outputRoot: this.inputRoot,
15456
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
15457
+ outputRoot: treeParent,
15458
+ relativeDirPath: treeCommandsDirPath,
13837
15459
  relativeFilePath: this.safeRelativePath(basePath, path)
13838
15460
  })));
15461
+ }
15462
+ /**
15463
+ * Implementation of abstract method from FeatureProcessor
15464
+ * Load and parse rulesync command files from every configured input root's
15465
+ * `.rulesync/commands/` directory, merging by relative path so a command
15466
+ * with the same target path from a later root replaces the earlier root's
15467
+ * copy.
15468
+ */
15469
+ async loadRulesyncFiles() {
15470
+ const rulesyncCommands = mergeByCaseInsensitiveIdentity({
15471
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
15472
+ identity: (command) => command.getRelativeFilePath(),
15473
+ artifactName: "command",
15474
+ logger: this.logger
15475
+ });
13839
15476
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13840
15477
  return rulesyncCommands;
13841
15478
  }
@@ -19475,6 +21112,7 @@ function vibeEntryToCanonicalDef(raw) {
19475
21112
  const entry = raw;
19476
21113
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19477
21114
  if (vibeEvent === void 0) return null;
21115
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19478
21116
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19479
21117
  const def = { type: "command" };
19480
21118
  if (typeof entry.command === "string") def.command = entry.command;
@@ -20023,10 +21661,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
20023
21661
  var HooksProcessor = class extends FeatureProcessor {
20024
21662
  toolTarget;
20025
21663
  global;
20026
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
21664
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
20027
21665
  super({
20028
21666
  outputRoot,
20029
- inputRoot,
21667
+ inputRoots,
20030
21668
  dryRun,
20031
21669
  logger
20032
21670
  });
@@ -20036,9 +21674,17 @@ var HooksProcessor = class extends FeatureProcessor {
20036
21674
  this.global = global;
20037
21675
  }
20038
21676
  async loadRulesyncFiles() {
21677
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
21678
+ const sourceTree = await pickLastRootWithFile({
21679
+ inputRoots: this.inputRoots,
21680
+ relativePaths,
21681
+ logger: this.logger,
21682
+ artifactName: "The hooks file"
21683
+ }) ?? this.inputRoots[0];
20039
21684
  try {
20040
21685
  return [await RulesyncHooks.fromFile({
20041
- outputRoot: this.inputRoot,
21686
+ outputRoot: (0, node_path.dirname)(sourceTree),
21687
+ relativeDirPath: (0, node_path.basename)(sourceTree),
20042
21688
  validate: true
20043
21689
  })];
20044
21690
  } catch (error) {
@@ -21580,10 +23226,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21580
23226
  getFactory;
21581
23227
  featureOptions;
21582
23228
  global;
21583
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
23229
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
21584
23230
  super({
21585
23231
  outputRoot,
21586
- inputRoot,
23232
+ inputRoots,
21587
23233
  dryRun,
21588
23234
  logger
21589
23235
  });
@@ -21600,11 +23246,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21600
23246
  }
21601
23247
  /**
21602
23248
  * Implementation of abstract method from FeatureProcessor
21603
- * Load and parse rulesync ignore files from .rulesync/ignore/ directory
23249
+ *
23250
+ * Load and parse the rulesync ignore file. `inputRoots[i]` is a source
23251
+ * tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
23252
+ * lives directly inside it. The legacy `.rulesyncignore` is shared at the
23253
+ * project root, so it is intentionally not considered when choosing which
23254
+ * source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
23255
+ * for the chosen tree.
23256
+ *
23257
+ * When multiple input roots are configured, the last root that provides
23258
+ * an ignore file wins entirely (whole-file replacement — no line-level
23259
+ * merge in this slice; see the "Deliberately out of scope" section of
23260
+ * the inputRoots plan for context). If no root has the file, fall back
23261
+ * to the primary root's path so the underlying `RulesyncIgnore.fromFile`
23262
+ * surfaces the same missing-file error it would in the single-root case.
21604
23263
  */
21605
23264
  async loadRulesyncFiles() {
23265
+ const paths = RulesyncIgnore.getSettablePaths();
23266
+ const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
23267
+ const sourceTree = await pickLastRootWithFile({
23268
+ inputRoots: this.inputRoots,
23269
+ relativePaths,
23270
+ logger: this.logger,
23271
+ artifactName: "The ignore file (.aiignore)"
23272
+ }) ?? this.inputRoots[0];
21606
23273
  try {
21607
- return [await RulesyncIgnore.fromFile({ outputRoot: this.inputRoot })];
23274
+ return [await RulesyncIgnore.fromFile({
23275
+ outputRoot: (0, node_path.dirname)(sourceTree),
23276
+ relativeDirPath: (0, node_path.basename)(sourceTree)
23277
+ })];
21608
23278
  } catch (error) {
21609
23279
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21610
23280
  return [];
@@ -26639,11 +28309,86 @@ function disabledNamesOf(config) {
26639
28309
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26640
28310
  }
26641
28311
  /**
28312
+ * The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
28313
+ * `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
28314
+ * always POSIX-separated.
28315
+ */
28316
+ const ROVODEV_PROJECT_MCP_CONFIG_POINTER = node_path.posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
28317
+ /**
28318
+ * The keys that make an entry in `mcp.json` something Rovo Dev can start:
28319
+ * a local process, or a remote endpoint under either spelling the canonical
28320
+ * config accepts.
28321
+ */
28322
+ const MCP_SERVER_ENDPOINT_KEYS = [
28323
+ "command",
28324
+ "url",
28325
+ "httpUrl"
28326
+ ];
28327
+ function normalizeMcpConfigPathValue(value) {
28328
+ return toPosixPath(value).replace(/^\.\//, "");
28329
+ }
28330
+ /**
28331
+ * Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
28332
+ * and report whether the block gained a value it did not already carry.
28333
+ *
28334
+ * Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
28335
+ * directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
28336
+ * config points at it — the Bitbucket Agentic Pipelines guide documents
28337
+ * registering the server and setting the pointer as two required steps. Global
28338
+ * scope is left alone: there the default already resolves to the file rulesync
28339
+ * writes.
28340
+ *
28341
+ * The pointer names one config rather than merging with the default, so it is
28342
+ * written only when this project actually has a Rovo Dev server to run — a
28343
+ * server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
28344
+ * generated empty, and pointing at it would take away the user's global
28345
+ * servers for this repository in exchange for nothing.
28346
+ *
28347
+ * That condition can also stop holding after the fact, once the last server is
28348
+ * removed from the canonical config or switched off. Rulesync does not take
28349
+ * the pointer back out — it cannot tell its own past value from a user who
28350
+ * typed the same string — but it says so, since the file is now a live setting
28351
+ * that resolves to nothing.
28352
+ *
28353
+ * A pointer the user aimed somewhere else is theirs, not ours: overwriting it
28354
+ * would silently redirect Rovo Dev away from a file they chose. It is named in
28355
+ * a warning instead, because the generated `mcp.json` is unread while it
28356
+ * stands.
28357
+ *
28358
+ * Whatever the outcome, it is logged: writing the pointer turns servers that
28359
+ * were generated-but-never-read into servers Rovo Dev actually spawns, and
28360
+ * points it away from the global MCP config, so it is not something to do
28361
+ * quietly.
28362
+ *
28363
+ * @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
28364
+ * @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
28365
+ */
28366
+ function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
28367
+ if (global) return false;
28368
+ const existing = existingMcp.mcpConfigPath;
28369
+ const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
28370
+ if (!hasLiveServers) {
28371
+ if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which now has no enabled server. Rovo Dev reads that file instead of the global MCP config, so this project has no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to the global config.`);
28372
+ return false;
28373
+ }
28374
+ if (existing === void 0) {
28375
+ existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
28376
+ logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
28377
+ return true;
28378
+ }
28379
+ if (pointsAtGeneratedFile) return false;
28380
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
28381
+ return false;
28382
+ }
28383
+ /**
26642
28384
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26643
28385
  * `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
26644
- * Rovo Dev actually consults to switch a server off — recomputed from the
26645
- * existing block so user keys (`mcpConfigPath`, `allowedMcpServers`, ...) and
26646
- * disabled names for servers rulesync does not manage survive.
28386
+ * Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
28387
+ * which rulesync authors in project scope when the key is absent and this
28388
+ * project has a server to run (see `applyProjectMcpConfigPointer`). The block
28389
+ * is recomputed from the existing one, so user keys (`allowedMcpServers`,
28390
+ * ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
28391
+ * servers rulesync does not manage all survive.
26647
28392
  */
26648
28393
  var RovodevMcpConfigYaml = class extends ToolFile {
26649
28394
  isDeletable() {
@@ -26776,7 +28521,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26776
28521
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26777
28522
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26778
28523
  else delete existingMcp.disabledMcpServers;
26779
- if (mergedDisabled.length === 0 && existingContent.trim() === "") return [];
28524
+ const wrotePointer = applyProjectMcpConfigPointer({
28525
+ existingMcp,
28526
+ global,
28527
+ hasLiveServers: managedNames.filter((name) => {
28528
+ if (disabledNames.includes(name)) return false;
28529
+ const server = servers[name];
28530
+ return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
28531
+ }).length > 0,
28532
+ logger
28533
+ });
28534
+ if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
26780
28535
  const fileContent = applySharedConfigPatch({
26781
28536
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26782
28537
  feature: "mcp",
@@ -27869,10 +29624,10 @@ var McpProcessor = class extends FeatureProcessor {
27869
29624
  toolTarget;
27870
29625
  global;
27871
29626
  getFactory;
27872
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
29627
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
27873
29628
  super({
27874
29629
  outputRoot,
27875
- inputRoot,
29630
+ inputRoots,
27876
29631
  dryRun,
27877
29632
  logger
27878
29633
  });
@@ -27888,7 +29643,10 @@ var McpProcessor = class extends FeatureProcessor {
27888
29643
  */
27889
29644
  async loadRulesyncFiles() {
27890
29645
  try {
27891
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
29646
+ return [await RulesyncMcp.fromRoots({
29647
+ inputRoots: this.inputRoots,
29648
+ logger: this.logger
29649
+ })];
27892
29650
  } catch (error) {
27893
29651
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27894
29652
  return [];
@@ -29430,27 +31188,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
29430
31188
  return { permission };
29431
31189
  }
29432
31190
  //#endregion
29433
- //#region src/utils/control-characters.ts
29434
- /**
29435
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
29436
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
29437
- * line and paragraph separators, and the plain LRM/RLM marks. A name or value
29438
- * copied out of an untrusted config file, a fetched repository, or a tool's own
29439
- * settings file must never reach the terminal with these intact: they let the
29440
- * text forge log lines, reorder what is printed around them, or inject escape
29441
- * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
29442
- * neutral characters beside them, so they go too — a diagnostic line is not the
29443
- * place to preserve the typography of a right-to-left name.
29444
- */
29445
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
29446
- /**
29447
- * Removes every control character from `text` so it is safe to splice into a
29448
- * log line or other terminal output.
29449
- */
29450
- function stripControlCharacters(text) {
29451
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
29452
- }
29453
- //#endregion
29454
31191
  //#region src/features/permissions/claudecode-permissions.ts
29455
31192
  /**
29456
31193
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -29536,8 +31273,8 @@ function deepMergeRecords(base, patch) {
29536
31273
  *
29537
31274
  * Deliberately NOT listed:
29538
31275
  * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
29539
- * `stripCommandExecutingSandboxPaths` refuses them in both scopes rather than
29540
- * emitting them under `--global`.
31276
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
31277
+ * rather than emitting them under `--global`.
29541
31278
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29542
31279
  * unit is the individual entry's mode, not the settings key, and the same
29543
31280
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29556,6 +31293,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29556
31293
  ["allowAppleEvents"]
29557
31294
  ];
29558
31295
  /**
31296
+ * `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
31297
+ * only from the settings file an organization deploys. Neither file rulesync
31298
+ * writes is that file, so they are dropped in **both** scopes — the `sandbox`
31299
+ * counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
31300
+ * for top-level `Managed` keys.
31301
+ *
31302
+ * Both only ever *narrow* the policy — they stop a lower-scoped file from
31303
+ * re-opening what managed settings blocked — so neither is trust-widening. They
31304
+ * are dropped rather than written for the opposite reason: written into a
31305
+ * project or user file they do nothing at all, and a `sandbox` block that reads
31306
+ * as though it locked the policy to managed values while Claude Code ignores it
31307
+ * is the more dangerous of the two failure modes.
31308
+ *
31309
+ * Import keeps them, unlike the command-executing paths: the value in an
31310
+ * existing `settings.json` was hand-written to be honored somewhere, and
31311
+ * round-tripping it preserves the author's intent for the day it moves into a
31312
+ * managed file. The cost is a warning on every generate until it is removed,
31313
+ * which the refusal message points at.
31314
+ *
31315
+ * @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
31316
+ * — "Scope: `Managed`"; the `network` entry says the same.
31317
+ */
31318
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
31319
+ /**
29559
31320
  * Walks `segments` from `root`, returning the record they name or `undefined` if
29560
31321
  * any step is missing or not a record. Shared by everything below that addresses
29561
31322
  * a `sandbox` path, so a nested path added to one of the tables is actually
@@ -29602,6 +31363,18 @@ function deleteSandboxPath({ target, path }) {
29602
31363
  return true;
29603
31364
  }
29604
31365
  /**
31366
+ * The one warning that names every trust-affecting setting this generate wrote
31367
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
31368
+ * matter, but the "review this as you would a hook" framing only needs saying
31369
+ * once, and repeating it per key buries the reasons in boilerplate.
31370
+ */
31371
+ function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
31372
+ if (entries.length === 0) return;
31373
+ const one = entries.length === 1;
31374
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
31375
+ logger?.warn(`Claude Code permissions: writing ${entries.length} trust-affecting ${one ? "setting" : "settings"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
31376
+ }
31377
+ /**
29605
31378
  * The `permissions.defaultMode` values that start a session with fewer prompts
29606
31379
  * than the default. `plan` and `default` are absent because they do not widen
29607
31380
  * anything.
@@ -29614,14 +31387,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
29614
31387
  /**
29615
31388
  * The `permissions` fields that widen rather than restrict: a `defaultMode` that
29616
31389
  * removes prompts, and `additionalDirectories`, which moves the
29617
- * working-directory boundary. Warned for the same reason `disableAllHooks` is: a
29618
- * shareable permissions file should not loosen the permission system quietly.
31390
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
31391
+ * a shareable permissions file should not loosen the permission system quietly.
29619
31392
  */
29620
- function warnOnWideningPermissionFields({ fields, relativeFilePath, logger }) {
31393
+ function collectWideningPermissionFields({ fields }) {
31394
+ const entries = [];
29621
31395
  const defaultMode = fields.defaultMode;
29622
- if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) logger?.warn(`Claude Code permissions: writing 'permissions.defaultMode: "${defaultMode}"' to ${relativeFilePath}; ${CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]}. Review it as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
31396
+ if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
31397
+ label: `permissions.defaultMode: "${defaultMode}"`,
31398
+ reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
31399
+ });
29623
31400
  const additionalDirectories = fields.additionalDirectories;
29624
- if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) logger?.warn(`Claude Code permissions: writing 'permissions.additionalDirectories' to ${relativeFilePath}; it moves the boundary of what Claude Code may read and edit outside the project. Review the paths, especially if this permissions file came from 'rulesync fetch'.`);
31401
+ if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
31402
+ label: "permissions.additionalDirectories",
31403
+ reason: "moves the boundary of what Claude Code may read and edit outside the project"
31404
+ });
31405
+ return entries;
29625
31406
  }
29626
31407
  /**
29627
31408
  * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
@@ -29638,6 +31419,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
29638
31419
  ["socatPath"]
29639
31420
  ];
29640
31421
  /**
31422
+ * The predicates the "which value actually widens?" tables are built from.
31423
+ * Each names the value that does *not* widen and reports everything else, never
31424
+ * the reverse: the override is authored JSONC, so a key can carry any value at
31425
+ * all, and one Claude Code coerces is still honored. Reporting an off-type value
31426
+ * keeps the warning fail-safe — silence has to mean "this cannot loosen
31427
+ * anything", not "this is not the type the table expected".
31428
+ */
31429
+ const isNotFalse = (value) => value !== false;
31430
+ const isNotTrue = (value) => value !== true;
31431
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
31432
+ const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
31433
+ /**
29641
31434
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
29642
31435
  * they let commands out of it, weaken the isolation it provides, or redirect
29643
31436
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -29657,77 +31450,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29657
31450
  {
29658
31451
  path: ["allowAppleEvents"],
29659
31452
  reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
29660
- widens: (value) => value === true
31453
+ widens: isNotFalse
29661
31454
  },
29662
31455
  {
29663
31456
  path: ["allowUnsandboxedCommands"],
29664
31457
  reason: "controls whether Claude may retry a blocked command outside the sandbox",
29665
- widens: (value) => value !== false
31458
+ widens: isNotFalse
29666
31459
  },
29667
31460
  {
29668
31461
  path: ["autoAllowBashIfSandboxed"],
29669
31462
  reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
29670
- widens: (value) => value !== false
31463
+ widens: isNotFalse
29671
31464
  },
29672
31465
  {
29673
31466
  path: ["enableWeakerNestedSandbox"],
29674
31467
  reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
29675
- widens: (value) => value === true
31468
+ widens: isNotFalse
29676
31469
  },
29677
31470
  {
29678
31471
  path: ["enableWeakerNetworkIsolation"],
29679
31472
  reason: "weakens the sandbox's network isolation on macOS",
29680
- widens: (value) => value === true
31473
+ widens: isNotFalse
29681
31474
  },
29682
31475
  {
29683
31476
  path: ["enabled"],
29684
31477
  reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
29685
- widens: (value) => value === true
31478
+ widens: isNotFalse
29686
31479
  },
29687
31480
  {
29688
31481
  path: ["excludedCommands"],
29689
31482
  reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
29690
- widens: (value) => !Array.isArray(value) || value.length > 0
31483
+ widens: isNonEmptyList
29691
31484
  },
29692
31485
  {
29693
31486
  path: ["filesystem", "allowRead"],
29694
31487
  reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
29695
- widens: (value) => !Array.isArray(value) || value.length > 0
31488
+ widens: isNonEmptyList
29696
31489
  },
29697
31490
  {
29698
31491
  path: ["filesystem", "allowWrite"],
29699
31492
  reason: "adds paths sandboxed commands may write to, outside the working directory",
29700
- widens: (value) => !Array.isArray(value) || value.length > 0
31493
+ widens: isNonEmptyList
29701
31494
  },
29702
31495
  {
29703
31496
  path: ["ignoreViolations"],
29704
31497
  reason: "hides the sandbox violations it names, so a blocked access stops being reported",
29705
- widens: (value) => isPlainRecord(value) ? Object.keys(value).length > 0 : value !== false
31498
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
29706
31499
  },
29707
31500
  {
29708
31501
  path: ["network", "allowAllUnixSockets"],
29709
31502
  reason: "lets sandboxed commands connect to every Unix socket",
29710
- widens: (value) => value === true
31503
+ widens: isNotFalse
29711
31504
  },
29712
31505
  {
29713
31506
  path: ["network", "allowedDomains"],
29714
31507
  reason: "pre-allows domains sandboxed commands may reach without a prompt",
29715
- widens: (value) => !Array.isArray(value) || value.length > 0
31508
+ widens: isNonEmptyList
29716
31509
  },
29717
31510
  {
29718
31511
  path: ["network", "allowLocalBinding"],
29719
31512
  reason: "lets sandboxed commands bind local ports",
29720
- widens: (value) => value === true
31513
+ widens: isNotFalse
29721
31514
  },
29722
31515
  {
29723
31516
  path: ["network", "allowMachLookup"],
29724
31517
  reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
29725
- widens: (value) => !Array.isArray(value) || value.length > 0
31518
+ widens: isNonEmptyList
29726
31519
  },
29727
31520
  {
29728
31521
  path: ["network", "allowUnixSockets"],
29729
31522
  reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
29730
- widens: (value) => !Array.isArray(value) || value.length > 0
31523
+ widens: isNonEmptyList
29731
31524
  },
29732
31525
  {
29733
31526
  path: ["network", "httpProxyPort"],
@@ -29741,11 +31534,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29741
31534
  }
29742
31535
  ];
29743
31536
  /**
29744
- * Warns once per authored `sandbox` path that loosens the sandbox. Nothing is
29745
- * removed — the value is written, just not silently. Called on the filtered
29746
- * `sandbox` so it never claims to be writing a path the scope filters dropped.
31537
+ * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
31538
+ * the values are written, just not silently. Called on the filtered `sandbox`
31539
+ * so it never claims to be writing a path the scope filters dropped.
29747
31540
  */
29748
- function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger }) {
31541
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
31542
+ const entries = [];
29749
31543
  for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29750
31544
  const leaf = path.at(-1);
29751
31545
  if (leaf === void 0) continue;
@@ -29756,38 +31550,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
29756
31550
  if (parent === void 0) continue;
29757
31551
  const value = parent[leaf];
29758
31552
  if (value === void 0 || !widens(value)) continue;
29759
- logger?.warn(`Claude Code permissions: writing 'sandbox.${path.join(".")}' to ${relativeFilePath}; it ${reason}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
31553
+ entries.push({
31554
+ label: `sandbox.${path.join(".")}`,
31555
+ reason
31556
+ });
29760
31557
  }
31558
+ return entries;
29761
31559
  }
31560
+ /** Paths that name an executable Claude Code runs. Refused in both scopes. */
31561
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
31562
+ paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
31563
+ warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' names an executable Claude Code runs, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; set this path in ${relativeFilePath} by hand.`
31564
+ };
29762
31565
  /**
29763
- * Copy of the authored `sandbox` override with the paths that name an
29764
- * executable removed, warning once per dropped path.
31566
+ * Paths Claude Code honors only from managed settings. Refused in both scopes,
31567
+ * like the command-executing paths, because managed settings are not a file
31568
+ * rulesync writes in either of them.
29765
31569
  */
29766
- function stripCommandExecutingSandboxPaths({ sandbox, relativeFilePath, logger }) {
29767
- const filtered = structuredClone(sandbox);
29768
- for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) {
29769
- if (!deleteSandboxPath({
29770
- target: filtered,
29771
- path
29772
- })) continue;
29773
- logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' names an executable Claude Code runs, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; set this path in ${relativeFilePath} by hand.`);
29774
- }
29775
- return filtered;
29776
- }
31570
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
31571
+ paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
31572
+ warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' is only honored in managed settings, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in the managed settings file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`
31573
+ };
31574
+ /** Paths Claude Code honors only above project scope. Refused at project scope. */
31575
+ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
31576
+ paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
31577
+ warn: ({ label, relativeFilePath }) => `Claude Code permissions: '${label}' is only honored in user/managed/--settings settings, so it is not written to the project-scoped ${relativeFilePath}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`
31578
+ };
29777
31579
  /**
29778
- * Copy of the authored `sandbox` override with the user/managed-only paths
29779
- * removed, warning once per dropped path. Only the override copy is filtered —
29780
- * a value already hand-written in the target file is left untouched, matching
29781
- * the `qwencode` `security.allowPrivateNetworkHooks` precedent.
31580
+ * Copy of the authored `sandbox` override with every path of every passed
31581
+ * refusal removed, warning once per dropped path. Only the override copy is
31582
+ * filtered — a value already hand-written in the target file is left untouched,
31583
+ * matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
29782
31584
  */
29783
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
31585
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
29784
31586
  const filtered = structuredClone(sandbox);
29785
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
31587
+ for (const { paths, warn } of refusals) for (const path of paths) {
29786
31588
  if (!deleteSandboxPath({
29787
31589
  target: filtered,
29788
31590
  path
29789
31591
  })) continue;
29790
- logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' is only honored in user/managed/--settings settings, so it is not written to the project-scoped ${relativeFilePath}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
31592
+ logger?.warn(warn({
31593
+ label: `sandbox.${path.join(".")}`,
31594
+ relativeFilePath
31595
+ }));
29791
31596
  }
29792
31597
  return filtered;
29793
31598
  }
@@ -29811,7 +31616,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29811
31616
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29812
31617
  * against cannot slip through a differently-spelled value.
29813
31618
  *
29814
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
31619
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29815
31620
  * value already in the target file is left untouched, which is why the warning
29816
31621
  * points at it.
29817
31622
  *
@@ -29978,6 +31783,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29978
31783
  allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
29979
31784
  allowedMcpServers: "allowlists the MCP servers that may be used, and entries from every settings file merge into one list, so an entry here widens an allowlist deployed elsewhere",
29980
31785
  autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
31786
+ claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
31787
+ companyAnnouncements: "prints the strings it names at startup as your organization's announcement, so a fetched value speaks to the reader with your organization's voice",
31788
+ crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
29981
31789
  disableAllHooks: "controls whether hooks run at all",
29982
31790
  disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
29983
31791
  enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
@@ -29986,17 +31794,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29986
31794
  env: "sets environment variables for every process Claude Code spawns, so a value such as `NODE_OPTIONS` or `PATH` runs code and `ANTHROPIC_BASE_URL` redirects every prompt",
29987
31795
  extraKnownMarketplaces: "registers plugin marketplace sources",
29988
31796
  httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
31797
+ modelOverrides: "maps the model IDs it names to provider-specific model IDs, so an entry decides which inference profile or deployment every call for that model is routed to",
29989
31798
  outputStyle: "replaces the system prompt every session runs with",
31799
+ prUrlTemplate: "rewrites the pull-request links Claude Code renders, so they can point at a host of the template's choosing rather than at the reviewed PR",
31800
+ remoteControlAtStartup: "connects Remote Control automatically at session start, and the transcript of a connected session is stored on Anthropic servers to sync it across devices",
29990
31801
  skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
29991
- skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
31802
+ skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
31803
+ skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
29992
31804
  };
29993
31805
  /**
29994
31806
  * The keys from the table above that only widen at one particular value.
29995
31807
  * `disableSkillShellExecution: true` turns inline shell execution off, which
29996
- * restricts; the `false` that turns it back on is what a fetched override could
29997
- * use to undo a user setting, so only that value is warned about.
29998
- */
29999
- const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = { disableSkillShellExecution: (value) => value === false };
31808
+ * restricts; anything else re-opens it, and that is what a fetched override
31809
+ * could use to undo a user setting. The rest default to off, so only a value
31810
+ * other than the one that leaves them off is worth a line — and for the
31811
+ * list-valued and map-valued keys, only a non-empty one, since an empty list or
31812
+ * map excludes, announces and overrides nothing.
31813
+ */
31814
+ const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
31815
+ claudeMdExcludes: isNonEmptyList,
31816
+ companyAnnouncements: isNonEmptyList,
31817
+ crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
31818
+ disableSkillShellExecution: isNotTrue,
31819
+ modelOverrides: isNonEmptyMap,
31820
+ remoteControlAtStartup: isNotFalse,
31821
+ skipWebFetchPreflight: isNotFalse
31822
+ };
31823
+ /**
31824
+ * Top-level keys a project-scoped `.claude/settings.json` honors at one value
31825
+ * but ignores at another — the value-level counterpart of
31826
+ * {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
31827
+ * value is dropped at project scope for the same reason a wholly unhonored key
31828
+ * is: committing it would read as a policy that never applies.
31829
+ *
31830
+ * `remoteControlAtStartup` is the only entry whose honored value can be decided
31831
+ * from the value alone. Claude Code honors a `false` from project or local
31832
+ * settings — a repository may turn auto-connect off for its own checkout — but
31833
+ * ignores a `true`, so that a checked-in file cannot turn Remote Control on for
31834
+ * everyone who opens the repository.
31835
+ *
31836
+ * `crossSessionInbound` is on the same documented list but deliberately absent
31837
+ * here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
31838
+ * honored only when it is stricter than the one above it, which no per-value
31839
+ * predicate can decide without reading the user's own settings. It is warned
31840
+ * about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
31841
+ * `--global` its loosening value is honored outright.
31842
+ *
31843
+ * @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
31844
+ */
31845
+ const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
31846
+ ignored: isNotFalse,
31847
+ note: "Claude Code honors only a `false` there, so that a checked-in file cannot turn Remote Control on for everyone who opens the repository"
31848
+ } };
30000
31849
  /**
30001
31850
  * A key name is authored data that ends up in a log line, so strip the control
30002
31851
  * characters that would let it forge a line or hide the warnings beside it, and
@@ -30024,12 +31873,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
30024
31873
  /**
30025
31874
  * Copy of the authored top-level passthrough with the keys the target file
30026
31875
  * cannot honor removed, warning once per dropped key. Like
30027
- * `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a value
31876
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30028
31877
  * already hand-written in the target file is left untouched, which is why the
30029
31878
  * warning points at it.
30030
31879
  */
30031
31880
  function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30032
31881
  const filtered = {};
31882
+ const trustAffecting = [];
30033
31883
  for (const [key, value] of Object.entries(overrides)) {
30034
31884
  const shown = displayKey(key);
30035
31885
  const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
@@ -30045,11 +31895,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
30045
31895
  logger?.warn(`Claude Code permissions: '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — Claude Code reads it from user, local or managed settings. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
30046
31896
  continue;
30047
31897
  }
30048
- const widensAtValue = CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey];
30049
- if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) logger?.warn(`Claude Code permissions: writing '${shown}' to ${relativeFilePath}; it ${CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
31898
+ const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
31899
+ if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
31900
+ logger?.warn(`Claude Code permissions: this value of '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — ${projectIgnored.note}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
31901
+ continue;
31902
+ }
31903
+ const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
31904
+ if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
31905
+ label: shown,
31906
+ reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
31907
+ });
30050
31908
  filtered[key] = value;
30051
31909
  }
30052
- return filtered;
31910
+ return {
31911
+ filtered,
31912
+ trustAffecting
31913
+ };
30053
31914
  }
30054
31915
  const CLAUDE_PATH_RULE_ALIASES = {
30055
31916
  Write: "Edit",
@@ -30119,15 +31980,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30119
31980
  config,
30120
31981
  logger
30121
31982
  });
31983
+ const trustAffecting = [];
30122
31984
  const overridePermissions = config.claudecode?.permissions;
30123
31985
  if (overridePermissions && typeof overridePermissions === "object") {
30124
31986
  const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
30125
31987
  const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
30126
- warnOnWideningPermissionFields({
30127
- fields: nonListFields,
30128
- relativeFilePath: paths.relativeFilePath,
30129
- logger
30130
- });
31988
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
30131
31989
  settings.permissions = {
30132
31990
  ...settings.permissions,
30133
31991
  ...nonListFields
@@ -30135,25 +31993,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30135
31993
  }
30136
31994
  const overrideSandbox = config.claudecode?.sandbox;
30137
31995
  if (isPlainRecord(overrideSandbox)) {
30138
- const executableFreeSandbox = stripCommandExecutingSandboxPaths({
31996
+ const honorableSandbox = stripSandboxPaths({
30139
31997
  sandbox: overrideSandbox,
31998
+ refusals: [
31999
+ CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
32000
+ CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
32001
+ ...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
32002
+ ],
30140
32003
  relativeFilePath: paths.relativeFilePath,
30141
32004
  logger
30142
32005
  });
30143
- const scopedSandbox = global ? executableFreeSandbox : stripProjectIgnoredMaskEntries({
30144
- sandbox: stripGlobalOnlySandboxPaths({
30145
- sandbox: executableFreeSandbox,
30146
- relativeFilePath: paths.relativeFilePath,
30147
- logger
30148
- }),
30149
- relativeFilePath: paths.relativeFilePath,
30150
- logger
30151
- });
30152
- warnOnTrustAffectingSandboxPaths({
30153
- sandbox: scopedSandbox,
32006
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
32007
+ sandbox: honorableSandbox,
30154
32008
  relativeFilePath: paths.relativeFilePath,
30155
32009
  logger
30156
32010
  });
32011
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
30157
32012
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
30158
32013
  }
30159
32014
  const overrideTopLevel = {};
@@ -30163,13 +32018,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30163
32018
  if (value === void 0) continue;
30164
32019
  overrideTopLevel[key] = value;
30165
32020
  }
30166
- const scopedTopLevel = stripUnhonoredTopLevelKeys({
32021
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
30167
32022
  overrides: overrideTopLevel,
30168
32023
  global,
30169
32024
  relativeFilePath: paths.relativeFilePath,
30170
32025
  logger
30171
32026
  });
32027
+ trustAffecting.push(...trustAffectingTopLevel);
30172
32028
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
32029
+ warnOnTrustAffectingEntries({
32030
+ entries: trustAffecting,
32031
+ relativeFilePath: paths.relativeFilePath,
32032
+ logger
32033
+ });
30173
32034
  const managedToolNames = managedClaudeToolNames(config);
30174
32035
  const merged = applyPermissions({
30175
32036
  settings,
@@ -35147,11 +37008,8 @@ const TOOL_KEY_TO_CATEGORY = {
35147
37008
  updateConfluencePage: "edit"
35148
37009
  };
35149
37010
  const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
35150
- const OWNED_TOOL_PERMISSION_KEYS = [
35151
- "bash",
35152
- "allowedExternalPaths",
35153
- "default"
35154
- ];
37011
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
37012
+ const MANAGED_BASH_KEYS = ["default", "commands"];
35155
37013
  /**
35156
37014
  * Permissions adapter for Rovo Dev CLI.
35157
37015
  *
@@ -35287,6 +37145,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35287
37145
  }
35288
37146
  };
35289
37147
  /**
37148
+ * Report a `toolPermissions.bash.runInSandbox: false` that this generate is
37149
+ * about to carry through.
37150
+ *
37151
+ * The unmanaged `bash` siblings survive untouched, which for this one means a
37152
+ * generate is not the reset a user might read it as: every command the agent
37153
+ * runs stays outside the sandbox. Rulesync never authors the key and will not
37154
+ * start owning it, but a setting that persists on a committed file and loosens
37155
+ * containment should not do so without saying anything — least of all on the
37156
+ * path where the user just tightened `.rulesync/permissions.*`.
37157
+ */
37158
+ function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
37159
+ const existingBash = existingToolPermissions.bash;
37160
+ if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
37161
+ logger?.warn(`${filePath}: keeping toolPermissions.bash.runInSandbox: false — rulesync does not manage that key, so regenerating does not restore Rovo Dev's sandbox for bash commands.`);
37162
+ }
37163
+ /**
35290
37164
  * Resolve the `toolPermissions` block to write, merging the generated levels
35291
37165
  * over the existing file. Every other top-level key of `config.yml` is the
35292
37166
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -35294,6 +37168,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35294
37168
  */
35295
37169
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
35296
37170
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
37171
+ warnAboutPreservedSandboxOptOut({
37172
+ existingToolPermissions,
37173
+ filePath,
37174
+ logger
37175
+ });
35297
37176
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
35298
37177
  if (!isRecord$1(existing)) return;
35299
37178
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -35302,9 +37181,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35302
37181
  }
35303
37182
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
35304
37183
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
37184
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
37185
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
35305
37186
  warnAboutDroppedOwnedKeys({
35306
37187
  existingToolPermissions,
35307
37188
  existingTools,
37189
+ existingBash,
35308
37190
  generated,
35309
37191
  filePath,
35310
37192
  logger
@@ -35314,15 +37196,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35314
37196
  delete existingToolPermissions[toolKey];
35315
37197
  delete existingTools[toolKey];
35316
37198
  }
37199
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
35317
37200
  const tools = {
35318
37201
  ...existingTools,
35319
37202
  ...generated.tools
35320
37203
  };
37204
+ const bash = {
37205
+ ...existingBash,
37206
+ ...generated.bash
37207
+ };
35321
37208
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
37209
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
35322
37210
  return {
35323
37211
  ...existingToolPermissions,
35324
37212
  ...generated,
35325
- ...Object.keys(tools).length > 0 ? { tools } : {}
37213
+ ...Object.keys(tools).length > 0 ? { tools } : {},
37214
+ ...Object.keys(bash).length > 0 ? { bash } : {}
35326
37215
  };
35327
37216
  }
35328
37217
  /**
@@ -35331,9 +37220,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35331
37220
  * `/directories` and by an "always allow" answer to a prompt — so their removal
35332
37221
  * must not be silent.
35333
37222
  */
35334
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
37223
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
35335
37224
  const newTools = generated.tools ?? {};
35336
- const droppedKeys = [...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0), ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`)];
37225
+ const newBash = generated.bash ?? {};
37226
+ const droppedKeys = [
37227
+ ...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
37228
+ ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
37229
+ ...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
37230
+ ];
35337
37231
  if (droppedKeys.length > 0) logger?.warn(`Rovo Dev permissions: removing ${droppedKeys.map((key) => `"${key}"`).join(", ")} from ${filePath} because the rulesync source no longer produces them.`);
35338
37232
  }
35339
37233
  /**
@@ -37441,10 +39335,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
37441
39335
  var PermissionsProcessor = class extends FeatureProcessor {
37442
39336
  toolTarget;
37443
39337
  global;
37444
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
39338
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
37445
39339
  super({
37446
39340
  outputRoot,
37447
- inputRoot,
39341
+ inputRoots,
37448
39342
  dryRun,
37449
39343
  logger
37450
39344
  });
@@ -37454,9 +39348,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
37454
39348
  this.global = global;
37455
39349
  }
37456
39350
  async loadRulesyncFiles() {
39351
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
39352
+ const sourceTree = await pickLastRootWithFile({
39353
+ inputRoots: this.inputRoots,
39354
+ relativePaths,
39355
+ logger: this.logger,
39356
+ artifactName: "The permissions file"
39357
+ }) ?? this.inputRoots[0];
37457
39358
  try {
37458
39359
  return [await RulesyncPermissions.fromFile({
37459
- outputRoot: this.inputRoot,
39360
+ outputRoot: (0, node_path.dirname)(sourceTree),
39361
+ relativeDirPath: (0, node_path.basename)(sourceTree),
37460
39362
  validate: true
37461
39363
  })];
37462
39364
  } catch (error) {
@@ -37604,9 +39506,13 @@ var SimulatedSkill = class extends ToolSkill {
37604
39506
  const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
37605
39507
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
37606
39508
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
37607
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
39509
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
37608
39510
  const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
37609
39511
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
39512
+ warnOnEmptyLoadedDescription({
39513
+ skillFilePath,
39514
+ description: result.data.description
39515
+ });
37610
39516
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
37611
39517
  return {
37612
39518
  outputRoot,
@@ -37880,13 +39786,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37880
39786
  //#region src/types/dir-feature-processor.ts
37881
39787
  var DirFeatureProcessor = class {
37882
39788
  outputRoot;
37883
- inputRoot;
39789
+ /**
39790
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
39791
+ * is a source tree itself — the directory that directly contains
39792
+ * feature subdirectories (`rules/`, `skills/`, …) and single-file
39793
+ * features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
39794
+ * when two trees supply the same relative path. Defaults to
39795
+ * `[join(process.cwd(), ".rulesync")]`.
39796
+ *
39797
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
39798
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
39799
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
39800
+ * reaches a processor.
39801
+ */
39802
+ inputRoots;
37884
39803
  dryRun;
37885
39804
  avoidBlockScalars;
37886
39805
  logger;
37887
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
39806
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37888
39807
  this.outputRoot = outputRoot;
37889
- this.inputRoot = inputRoot;
39808
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
37890
39809
  this.dryRun = dryRun;
37891
39810
  this.avoidBlockScalars = avoidBlockScalars;
37892
39811
  this.logger = logger;
@@ -40145,9 +42064,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
40145
42064
  * slug is owned by the commands feature: it must not be imported as a
40146
42065
  * skill nor deleted as an orphan skill.
40147
42066
  */
40148
- static async isDirOwned({ dirName, inputRoot }) {
42067
+ static async isDirOwned({ dirName, inputRoots }) {
40149
42068
  return !await rulesyncCommandSlugExists({
40150
- inputRoot,
42069
+ inputRoots,
40151
42070
  dirName
40152
42071
  });
40153
42072
  }
@@ -40197,7 +42116,11 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
40197
42116
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
40198
42117
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
40199
42118
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
40200
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
42119
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
42120
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
42121
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
42122
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
42123
+ version: zod_mini.z.optional(zod_mini.z.unknown())
40201
42124
  });
40202
42125
  /**
40203
42126
  * Represents a Factory Droid skill directory.
@@ -40250,16 +42173,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40250
42173
  };
40251
42174
  }
40252
42175
  toRulesyncSkill() {
40253
- const frontmatter = this.getFrontmatter();
40254
- const factorydroidBlock = {
40255
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
40256
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
40257
- ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
40258
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
40259
- };
42176
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
40260
42177
  const rulesyncFrontmatter = {
40261
- name: frontmatter.name,
40262
- description: frontmatter.description,
42178
+ name,
42179
+ description,
40263
42180
  targets: ["*"],
40264
42181
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
40265
42182
  };
@@ -40286,13 +42203,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40286
42203
  rootFrontmatter: rulesyncFrontmatter,
40287
42204
  section: factorydroidSection
40288
42205
  });
42206
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
40289
42207
  const factorydroidFrontmatter = {
40290
42208
  name: rulesyncFrontmatter.name,
40291
42209
  description: rulesyncFrontmatter.description,
42210
+ ...section,
40292
42211
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
40293
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
40294
- ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
40295
- ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
42212
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
40296
42213
  };
40297
42214
  return new FactorydroidSkill({
40298
42215
  outputRoot,
@@ -40704,7 +42621,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40704
42621
  }
40705
42622
  }
40706
42623
  static getSettablePaths(_options) {
40707
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
42624
+ return {
42625
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
42626
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
42627
+ };
40708
42628
  }
40709
42629
  getFrontmatter() {
40710
42630
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -41120,7 +43040,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
41120
43040
  }
41121
43041
  static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
41122
43042
  const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
41123
- const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
43043
+ const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
41124
43044
  const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
41125
43045
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
41126
43046
  const fileName = (0, node_path.basename)(relativeFilePath, (0, node_path.extname)(relativeFilePath));
@@ -41130,6 +43050,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
41130
43050
  name: result.data.name ?? fileName,
41131
43051
  description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
41132
43052
  };
43053
+ warnOnEmptyLoadedDescription({
43054
+ skillFilePath: filePath,
43055
+ description: normalizedFrontmatter.description
43056
+ });
41133
43057
  return new KimiCodeSkill({
41134
43058
  outputRoot,
41135
43059
  relativeDirPath,
@@ -42133,7 +44057,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
42133
44057
  static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
42134
44058
  const skillFilePath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
42135
44059
  try {
42136
- const { frontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
44060
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
42137
44061
  return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
42138
44062
  } catch {
42139
44063
  return true;
@@ -42502,8 +44426,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
42502
44426
  const fullPath = (0, node_path.join)(this.outputRoot, this.relativeDirPath);
42503
44427
  const resolvedFull = (0, node_path.resolve)(fullPath);
42504
44428
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
42505
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
42506
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
44429
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
42507
44430
  return fullPath;
42508
44431
  }
42509
44432
  getRelativePathFromCwd() {
@@ -42866,9 +44789,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42866
44789
  * slug is owned by the commands feature: it must not be imported as a
42867
44790
  * skill nor deleted as an orphan skill.
42868
44791
  */
42869
- static async isDirOwned({ dirName, inputRoot }) {
44792
+ static async isDirOwned({ dirName, inputRoots }) {
42870
44793
  return !await rulesyncCommandSlugExists({
42871
- inputRoot,
44794
+ inputRoots,
42872
44795
  dirName
42873
44796
  });
42874
44797
  }
@@ -43426,10 +45349,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43426
45349
  toolTarget;
43427
45350
  global;
43428
45351
  getFactory;
43429
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
45352
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
43430
45353
  super({
43431
45354
  outputRoot,
43432
- inputRoot,
45355
+ inputRoots,
43433
45356
  dryRun,
43434
45357
  avoidBlockScalars: toolTarget === "cursor",
43435
45358
  logger
@@ -43467,39 +45390,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43467
45390
  return rulesyncSkills;
43468
45391
  }
43469
45392
  /**
43470
- * Implementation of abstract method from DirFeatureProcessor
43471
- * Load and parse rulesync skill directories from .rulesync/skills/ directory
43472
- * and also from .rulesync/skills/.curated/ for remote skills.
43473
- * Local skills take precedence over curated skills with the same name.
45393
+ * Load rulesync skill directories from a single source-tree's `skills/`
45394
+ * (and `skills/.curated/`) subtree. `sourceTree` is the source tree
45395
+ * itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
45396
+ * local skills take precedence over curated skills with the same name.
43474
45397
  */
43475
- async loadRulesyncDirs() {
43476
- const localDirNames = [...await getLocalSkillDirNames(this.inputRoot)];
45398
+ async loadRulesyncDirsForRoot(sourceTree) {
45399
+ const treeParent = (0, node_path.dirname)(sourceTree);
45400
+ const treeName = (0, node_path.basename)(sourceTree);
45401
+ const treeSkillsDirPath = (0, node_path.join)(treeName, SKILLS_FEATURE_SUBDIR);
45402
+ const treeCuratedSkillsDirPath = (0, node_path.join)(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
45403
+ const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
43477
45404
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
43478
- outputRoot: this.inputRoot,
45405
+ outputRoot: treeParent,
45406
+ relativeDirPath: treeSkillsDirPath,
43479
45407
  dirName,
43480
45408
  global: this.global
43481
45409
  })));
43482
- const localSkillNames = new Set(localDirNames);
43483
- const curatedDirPath = (0, node_path.join)(this.inputRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
45410
+ const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
45411
+ const curatedDirPath = (0, node_path.join)(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
43484
45412
  let curatedSkills = [];
43485
45413
  if (await directoryExists(curatedDirPath)) {
43486
45414
  const nonConflicting = (await findFilesByGlobs((0, node_path.join)(curatedDirPath, "*"), { type: "dir" })).map((path) => (0, node_path.basename)(path)).filter((name) => {
43487
- if (localSkillNames.has(name)) {
43488
- this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
43489
- return false;
43490
- }
43491
- return true;
45415
+ const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
45416
+ if (spellings === void 0) return true;
45417
+ if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
45418
+ else this.logger.warn(formatCuratedCaseCollisionWarning({
45419
+ artifactKind: "skill",
45420
+ entryNoun: "skill",
45421
+ treeDirPath: treeSkillsDirPath,
45422
+ curatedSpelling: name,
45423
+ localSpellings: spellings
45424
+ }));
45425
+ return false;
43492
45426
  });
43493
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
43494
45427
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
43495
- outputRoot: this.inputRoot,
43496
- relativeDirPath: curatedRelativeDirPath,
45428
+ outputRoot: treeParent,
45429
+ relativeDirPath: treeCuratedSkillsDirPath,
43497
45430
  dirName,
43498
45431
  global: this.global
43499
45432
  })));
43500
45433
  }
43501
- const allSkills = [...localSkills, ...curatedSkills];
43502
- this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills (${localSkills.length} local, ${curatedSkills.length} curated)`);
45434
+ return [...localSkills, ...curatedSkills];
45435
+ }
45436
+ /**
45437
+ * Implementation of abstract method from DirFeatureProcessor.
45438
+ *
45439
+ * Load and parse rulesync skill directories from every configured input
45440
+ * root's `.rulesync/skills/` tree (each root also honours its own
45441
+ * `.curated/` subdirectory). When two roots supply a skill with the same
45442
+ * directory name, the later root's skill replaces the earlier root's copy
45443
+ * atomically (companion files included) — an overlay always ships a whole
45444
+ * skill directory, never a partial patch.
45445
+ */
45446
+ async loadRulesyncDirs() {
45447
+ const allSkills = mergeByCaseInsensitiveIdentity({
45448
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
45449
+ identity: (skill) => skill.getDirName(),
45450
+ artifactName: "skill",
45451
+ logger: this.logger
45452
+ });
45453
+ this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
43503
45454
  return allSkills;
43504
45455
  }
43505
45456
  /**
@@ -43515,7 +45466,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43515
45466
  }) : [];
43516
45467
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
43517
45468
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
43518
- const seenSkillNames = /* @__PURE__ */ new Set();
45469
+ const claimedSkillNames = new ClaimedIdentities();
45470
+ const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
45471
+ const skillName = skill.getImportIdentity();
45472
+ const claimed = claimedSkillNames.claim({
45473
+ identity: skillName,
45474
+ source: relativeDirPath
45475
+ });
45476
+ if (claimed === null) return true;
45477
+ if (claimed.spelling !== skillName) this.logger.warn(`Case-insensitive ${this.toolTarget} skill collision: "${claimed.spelling}" and "${skillName}" resolve to the same skill directory. Keeping "${claimed.spelling}" from ${claimed.source === relativeDirPath ? "earlier in the same root" : `the higher-precedence ${claimed.source}`} and ignoring ${sourcePath}, which is not imported.`);
45478
+ return false;
45479
+ };
43519
45480
  const toolSkills = [];
43520
45481
  for (const root of roots) {
43521
45482
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -43531,54 +45492,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43531
45492
  outputRoot: rootOutputRoot,
43532
45493
  relativeDirPath,
43533
45494
  dirName,
43534
- inputRoot: this.inputRoot
45495
+ inputRoots: this.inputRoots
43535
45496
  })) continue;
43536
45497
  ownedDirNames.push(dirName);
43537
45498
  }
43538
45499
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
45500
+ const sourcePath = (0, node_path.join)(relativeDirPath, dirName);
43539
45501
  try {
43540
- return await factory.class.fromDir({
43541
- outputRoot: rootOutputRoot,
43542
- relativeDirPath,
43543
- dirName,
43544
- global: this.global
43545
- });
45502
+ return {
45503
+ skill: await factory.class.fromDir({
45504
+ outputRoot: rootOutputRoot,
45505
+ relativeDirPath,
45506
+ dirName,
45507
+ global: this.global
45508
+ }),
45509
+ sourcePath
45510
+ };
43546
45511
  } catch (error) {
43547
45512
  if (!isLenientRoot) throw error;
43548
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, dirName)}: ${formatError(error)}`);
45513
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43549
45514
  return null;
43550
45515
  }
43551
- }))).filter((skill) => skill !== null);
43552
- for (const skill of directorySkills) {
43553
- const skillName = skill.getImportIdentity();
43554
- if (seenSkillNames.has(skillName)) continue;
43555
- seenSkillNames.add(skillName);
43556
- toolSkills.push(skill);
43557
- }
45516
+ }))).filter((loaded) => loaded !== null);
45517
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
45518
+ skill,
45519
+ relativeDirPath,
45520
+ sourcePath
45521
+ })) toolSkills.push(skill);
43558
45522
  if (!factory.class.fromFlatFile) continue;
43559
45523
  const fromFlatFile = factory.class.fromFlatFile;
43560
45524
  const directoryStems = new Set(ownedDirNames);
43561
45525
  const flatFilePaths = (await findFilesByGlobs((0, node_path.join)(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has((0, node_path.basename)(filePath, ".md")));
43562
45526
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
45527
+ const sourcePath = (0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath));
43563
45528
  try {
43564
- return await fromFlatFile({
43565
- outputRoot: rootOutputRoot,
43566
- relativeDirPath,
43567
- relativeFilePath: (0, node_path.basename)(filePath),
43568
- global: this.global
43569
- });
45529
+ return {
45530
+ skill: await fromFlatFile({
45531
+ outputRoot: rootOutputRoot,
45532
+ relativeDirPath,
45533
+ relativeFilePath: (0, node_path.basename)(filePath),
45534
+ global: this.global
45535
+ }),
45536
+ sourcePath
45537
+ };
43570
45538
  } catch (error) {
43571
45539
  if (!isLenientRoot) throw error;
43572
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath))}: ${formatError(error)}`);
45540
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43573
45541
  return null;
43574
45542
  }
43575
- }))).filter((skill) => skill !== null);
43576
- for (const skill of flatSkills) {
43577
- const skillName = skill.getImportIdentity();
43578
- if (seenSkillNames.has(skillName)) continue;
43579
- seenSkillNames.add(skillName);
43580
- toolSkills.push(skill);
43581
- }
45543
+ }))).filter((loaded) => loaded !== null);
45544
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
45545
+ skill,
45546
+ relativeDirPath,
45547
+ sourcePath
45548
+ })) toolSkills.push(skill);
43582
45549
  }
43583
45550
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43584
45551
  return toolSkills;
@@ -43608,7 +45575,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43608
45575
  outputRoot: this.outputRoot,
43609
45576
  relativeDirPath: root,
43610
45577
  dirName,
43611
- inputRoot: this.inputRoot
45578
+ inputRoots: this.inputRoots
43612
45579
  })) continue;
43613
45580
  const toolSkill = factory.class.forDeletion({
43614
45581
  outputRoot: this.outputRoot,
@@ -46366,6 +48333,7 @@ const JunieSubagentFrontmatterSchema = zod_mini.z.looseObject({
46366
48333
  disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
46367
48334
  mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
46368
48335
  model: zod_mini.z.optional(zod_mini.z.string()),
48336
+ permissionMode: zod_mini.z.optional(zod_mini.z.string()),
46369
48337
  reasoningLevel: zod_mini.z.optional(zod_mini.z.string()),
46370
48338
  maxTurns: zod_mini.z.optional(zod_mini.z.number()),
46371
48339
  skills: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
@@ -47425,7 +49393,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
47425
49393
  const paths = this.getSettablePaths({ global });
47426
49394
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
47427
49395
  const fileContent = await readFileContent(filePath);
47428
- const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
49396
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
47429
49397
  const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
47430
49398
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
47431
49399
  return new ReasonixSubagent({
@@ -47454,7 +49422,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
47454
49422
  static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
47455
49423
  const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
47456
49424
  try {
47457
- const { frontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
49425
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
47458
49426
  return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
47459
49427
  } catch {
47460
49428
  return false;
@@ -48346,14 +50314,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
48346
50314
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
48347
50315
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
48348
50316
  });
50317
+ /**
50318
+ * Stands in for a discovery root when a subagent came from a tool's own config
50319
+ * file rather than a directory (see `loadAdditionalImportFiles`). The angle
50320
+ * brackets keep it from ever matching a real relative directory path.
50321
+ */
50322
+ const INLINE_SOURCE = "<inline>";
50323
+ /**
50324
+ * The single "root" of the post-conversion output guard, which de-duplicates
50325
+ * `.rulesync/subagents/` paths rather than discovery roots.
50326
+ */
50327
+ const OUTPUT_SOURCE = "<output>";
48349
50328
  var SubagentsProcessor = class extends FeatureProcessor {
48350
50329
  toolTarget;
48351
50330
  global;
48352
50331
  getFactory;
48353
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
50332
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
48354
50333
  super({
48355
50334
  outputRoot,
48356
- inputRoot,
50335
+ inputRoots,
48357
50336
  dryRun,
48358
50337
  logger
48359
50338
  });
@@ -48399,24 +50378,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
48399
50378
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
48400
50379
  }
48401
50380
  const uniqueRulesyncSubagents = [];
48402
- const seenOutputPaths = /* @__PURE__ */ new Set();
50381
+ const claimedOutputPaths = new ClaimedIdentities();
48403
50382
  for (const rulesyncSubagent of rulesyncSubagents) {
48404
50383
  const outputPath = (0, node_path.join)(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
48405
- if (seenOutputPaths.has(outputPath)) {
48406
- this.logger.warn(`Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.`);
50384
+ const claimed = claimedOutputPaths.claim({
50385
+ identity: outputPath,
50386
+ source: OUTPUT_SOURCE
50387
+ });
50388
+ if (claimed !== null) {
50389
+ this.logger.warn(claimed.spelling === outputPath ? `Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.` : `${this.toolTarget} subagent "${outputPath}" differs only in case from "${claimed.spelling}", which is the same file on a case-insensitive filesystem; keeping the first and ignoring this copy.`);
48407
50390
  continue;
48408
50391
  }
48409
- seenOutputPaths.add(outputPath);
48410
50392
  uniqueRulesyncSubagents.push(rulesyncSubagent);
48411
50393
  }
48412
50394
  return uniqueRulesyncSubagents;
48413
50395
  }
48414
50396
  /**
48415
- * Implementation of abstract method from Processor
48416
- * Load and parse rulesync subagent files from .rulesync/subagents/ directory
50397
+ * Load subagent files from a single source-tree's `subagents/` subtree.
50398
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
50399
+ * `/repo/.rulesync.local`).
48417
50400
  */
48418
- async loadRulesyncFiles() {
48419
- const subagentsDir = (0, node_path.join)(this.inputRoot, RulesyncSubagent.getSettablePaths().relativeDirPath);
50401
+ async loadRulesyncFilesForRoot(sourceTree) {
50402
+ const treeParent = (0, node_path.dirname)(sourceTree);
50403
+ const treeName = (0, node_path.basename)(sourceTree);
50404
+ const treeSubagentsDirPath = (0, node_path.join)(treeName, SUBAGENTS_FEATURE_SUBDIR);
50405
+ const subagentsDir = (0, node_path.join)(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
48420
50406
  if (!await directoryExists(subagentsDir)) {
48421
50407
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
48422
50408
  return [];
@@ -48432,7 +50418,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
48432
50418
  const filepath = (0, node_path.join)(subagentsDir, mdFile);
48433
50419
  try {
48434
50420
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
48435
- outputRoot: this.inputRoot,
50421
+ outputRoot: treeParent,
50422
+ relativeDirPath: treeSubagentsDirPath,
48436
50423
  relativeFilePath: mdFile,
48437
50424
  validate: true
48438
50425
  });
@@ -48443,8 +50430,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
48443
50430
  continue;
48444
50431
  }
48445
50432
  }
50433
+ return rulesyncSubagents;
50434
+ }
50435
+ /**
50436
+ * Implementation of abstract method from Processor
50437
+ * Load and parse rulesync subagent files from every configured input root's
50438
+ * `.rulesync/subagents/` directory, merging by relative file path so a
50439
+ * subagent with the same target path from a later root replaces the
50440
+ * earlier root's copy.
50441
+ */
50442
+ async loadRulesyncFiles() {
50443
+ const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
50444
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
50445
+ identity: (subagent) => subagent.getRelativeFilePath(),
50446
+ artifactName: "subagent",
50447
+ logger: this.logger
50448
+ });
48446
50449
  if (rulesyncSubagents.length === 0) {
48447
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
50450
+ this.logger.debug(`No valid subagents found`);
48448
50451
  return [];
48449
50452
  }
48450
50453
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -48459,7 +50462,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
48459
50462
  const paths = factory.class.getSettablePaths({ global: this.global });
48460
50463
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
48461
50464
  const toolSubagents = [];
48462
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
50465
+ const claimedRelativeFilePaths = new ClaimedIdentities();
48463
50466
  for (const root of roots) {
48464
50467
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
48465
50468
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -48498,37 +50501,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
48498
50501
  relativeFilePath: toRelativeFilePath(path),
48499
50502
  global: this.global
48500
50503
  })));
48501
- const deduped = [];
48502
- for (const subagent of loaded) {
48503
- const key = subagent.getImportIdentity();
48504
- if (seenRelativeFilePaths.has(key)) {
48505
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping the one from a higher-precedence directory and ignoring this copy.`);
48506
- continue;
48507
- }
48508
- seenRelativeFilePaths.add(key);
48509
- deduped.push(subagent);
48510
- }
48511
- toolSubagents.push(...deduped);
50504
+ toolSubagents.push(...this.claimStandaloneSubagents({
50505
+ loaded,
50506
+ dirPath,
50507
+ claimedRelativeFilePaths
50508
+ }));
48512
50509
  }
48513
50510
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
48514
50511
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
48515
50512
  outputRoot: this.outputRoot,
48516
50513
  global: this.global
48517
50514
  });
48518
- for (const subagent of additionalSubagents) {
48519
- const key = subagent.getImportIdentity();
48520
- if (seenRelativeFilePaths.has(key)) {
48521
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping the standalone file and ignoring the inline copy.`);
48522
- continue;
48523
- }
48524
- seenRelativeFilePaths.add(key);
48525
- toolSubagents.push(subagent);
48526
- }
50515
+ toolSubagents.push(...this.claimInlineSubagents({
50516
+ additionalSubagents,
50517
+ claimedRelativeFilePaths
50518
+ }));
48527
50519
  }
48528
50520
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
48529
50521
  return toolSubagents;
48530
50522
  }
48531
50523
  /**
50524
+ * Keeps the subagents from one discovery root whose import identity is still
50525
+ * unclaimed, warning about each copy that loses. Split out of
50526
+ * `loadToolFiles` so the two de-duplication passes stay readable side by
50527
+ * side (and so that method stays within the linter's complexity budget).
50528
+ *
50529
+ * When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
50530
+ * plus `.agents/`), two roots can hold a subagent with the same relative
50531
+ * path. Downstream conversion keys by that path, so a later one would
50532
+ * silently overwrite an earlier one. Warn instead of failing, keeping the
50533
+ * earlier (higher-precedence) root's file.
50534
+ */
50535
+ claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
50536
+ const deduped = [];
50537
+ for (const subagent of loaded) {
50538
+ const key = subagent.getImportIdentity();
50539
+ const claimed = claimedRelativeFilePaths.claim({
50540
+ identity: key,
50541
+ source: dirPath
50542
+ });
50543
+ if (claimed === null) {
50544
+ deduped.push(subagent);
50545
+ continue;
50546
+ }
50547
+ const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
50548
+ this.logger.warn(claimed.spelling === key ? `Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping ${keptFrom} and ignoring this copy.` : `Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath} differs only in case from "${claimed.spelling}"; keeping ${keptFrom} and ignoring this copy.`);
50549
+ }
50550
+ return deduped;
50551
+ }
50552
+ /**
50553
+ * The same claim-or-warn pass for subagents defined inline in a tool's own
50554
+ * config file (see `loadAdditionalImportFiles`), which are scanned after
50555
+ * every standalone file so a Markdown file of the same name wins.
50556
+ */
50557
+ claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
50558
+ const deduped = [];
50559
+ for (const subagent of additionalSubagents) {
50560
+ const key = subagent.getImportIdentity();
50561
+ const claimed = claimedRelativeFilePaths.claim({
50562
+ identity: key,
50563
+ source: INLINE_SOURCE
50564
+ });
50565
+ if (claimed === null) {
50566
+ deduped.push(subagent);
50567
+ continue;
50568
+ }
50569
+ const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
50570
+ this.logger.warn(claimed.spelling === key ? `Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping ${kept} and ignoring the inline copy.` : `Inline ${this.toolTarget} subagent "${key}" differs only in case from "${claimed.spelling}"; keeping ${kept} and ignoring the inline copy.`);
50571
+ }
50572
+ return deduped;
50573
+ }
50574
+ /**
48532
50575
  * Implementation of abstract method from FeatureProcessor
48533
50576
  * Return the tool targets that this processor supports
48534
50577
  */
@@ -51458,17 +53501,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
51458
53501
  * Rule generator for JetBrains Junie AI coding agent
51459
53502
  *
51460
53503
  * Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
51461
- * resolves project guidelines **first-match-wins**: `.junie/AGENTS.md` → root
51462
- * `AGENTS.md` legacy `.junie/guidelines.md` / `.junie/guidelines/`. Only the
51463
- * first match is loaded, it documents no `@`-reference or file-inclusion
51464
- * mechanism, and no `.junie/memories/` read path exists so non-root rules
51465
- * are folded into the single root `.junie/AGENTS.md` by the RulesProcessor
51466
- * (`nonRoot` is `undefined`, mirroring the warp / deepagents
51467
- * targets; decision recorded in issue #2211). The legacy
53504
+ * resolves project guidelines in this order: `.junie/AGENTS.md` → root
53505
+ * `AGENTS.md` **combined with `.junie/playbook.md` and every
53506
+ * `.junie/rules/*.md`** legacy `.junie/guidelines.md` / `.junie/guidelines/`.
53507
+ * The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
53508
+ * is present: that file "is used exclusively and no other guidelines files are
53509
+ * combined with it". So emitting `.junie/rules/*.md` next to the root file
53510
+ * rulesync writes would produce files Junie never reads, and moving the root
53511
+ * output to project-root `AGENTS.md` would both change every existing output
53512
+ * path and collide with the `agentsmd` target. Non-root rules therefore stay
53513
+ * folded into the single root `.junie/AGENTS.md` by the RulesProcessor
53514
+ * (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
53515
+ * fold is lossless, since Junie loads that one file in full. The original
53516
+ * rationale for this shape was recorded in issue #2211 and re-confirmed
53517
+ * against the 2026-08-21 docs revision in issue #2728. The legacy
51468
53518
  * `.junie/guidelines.md` is still accepted as an import fallback, but
51469
53519
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
51470
53520
  * without frontmatter requirements.
51471
53521
  *
53522
+ * The multi-file branch is still read on **import**, though: a repo that
53523
+ * authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
53524
+ * layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
53525
+ * first `rulesync import` finds — declares those paths as `importOnlyRoots`
53526
+ * and imports them as non-root rules. They are never written back there:
53527
+ * generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
53528
+ * folded root file carries the same content.
53529
+ *
51472
53530
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
51473
53531
  * these user-scope guidelines with the project guidelines (both are included
51474
53532
  * and marked clearly).
@@ -51490,6 +53548,14 @@ var JunieRule = class JunieRule extends ToolRule {
51490
53548
  alternativeRoots: [{
51491
53549
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
51492
53550
  relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
53551
+ }],
53552
+ importOnlyRoots: [{
53553
+ relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
53554
+ onlyWhenRootAbsent: true
53555
+ }, {
53556
+ relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
53557
+ relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
53558
+ onlyWhenRootAbsent: true
51493
53559
  }]
51494
53560
  };
51495
53561
  }
@@ -51500,7 +53566,7 @@ var JunieRule = class JunieRule extends ToolRule {
51500
53566
  static isRootRelativeFilePath(relativeFilePath) {
51501
53567
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
51502
53568
  }
51503
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
53569
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
51504
53570
  if (global) {
51505
53571
  const paths = this.getSettablePaths({ global: true });
51506
53572
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -51514,7 +53580,8 @@ var JunieRule = class JunieRule extends ToolRule {
51514
53580
  root: true
51515
53581
  });
51516
53582
  }
51517
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
53583
+ const settablePaths = this.getSettablePaths();
53584
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
51518
53585
  const relativePath = (0, node_path.join)(relativeDirPath, relativeFilePath);
51519
53586
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
51520
53587
  return new JunieRule({
@@ -51523,7 +53590,7 @@ var JunieRule = class JunieRule extends ToolRule {
51523
53590
  relativeFilePath,
51524
53591
  fileContent,
51525
53592
  validate,
51526
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
53593
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
51527
53594
  });
51528
53595
  }
51529
53596
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53875,10 +55942,23 @@ const defaultGetFactory = (target) => {
53875
55942
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53876
55943
  return factory;
53877
55944
  };
53878
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53879
- const primaryFilePaths = await findFilesByGlobs(primaryGlob);
55945
+ /**
55946
+ * How many skipped import-only paths a single warning names before it
55947
+ * summarizes the rest. Keeps one line readable when a rules directory holds
55948
+ * dozens of files.
55949
+ */
55950
+ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
55951
+ /**
55952
+ * Fall back to a tool's legacy roots when its primary root file is absent.
55953
+ *
55954
+ * The primary hits are passed in rather than globbed here, so that callers
55955
+ * which need "the root file Rulesync generates" — rather than "whatever root
55956
+ * the tool will read" — can keep the two apart. A legacy root is a file
55957
+ * Rulesync reads but never writes, and the difference matters to them.
55958
+ */
55959
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
53880
55960
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53881
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
55961
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53882
55962
  return [];
53883
55963
  };
53884
55964
  var RulesProcessor = class extends FeatureProcessor {
@@ -53890,10 +55970,10 @@ var RulesProcessor = class extends FeatureProcessor {
53890
55970
  getFactory;
53891
55971
  skills;
53892
55972
  featureOptions;
53893
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
55973
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
53894
55974
  super({
53895
55975
  outputRoot,
53896
- inputRoot,
55976
+ inputRoots,
53897
55977
  dryRun,
53898
55978
  logger
53899
55979
  });
@@ -54268,25 +56348,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
54268
56348
  claimedBy.set(target.toLowerCase(), source);
54269
56349
  continue;
54270
56350
  }
54271
- this.logger.warn(`Both ${previous} and ${source} import to ${(0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
56351
+ this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters((0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
54272
56352
  }
54273
56353
  return rulesyncRules;
54274
56354
  }
54275
56355
  /**
54276
- * Implementation of abstract method from FeatureProcessor
54277
- * Load and parse rulesync rule files from .rulesync/rules/ directory
56356
+ * Load rulesync rule files from a single source-tree's `rules/` (and
56357
+ * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
56358
+ * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
56359
+ *
56360
+ * Intra-tree behavior — the local-wins-over-curated rule and the
56361
+ * case-insensitive collision handling — is preserved from the previous
56362
+ * single-root implementation. See `loadRulesyncFiles` for how the
56363
+ * per-root results are combined into the effective set.
54278
56364
  */
54279
- async loadRulesyncFiles() {
54280
- const rulesyncOutputRoot = (0, node_path.join)(this.inputRoot, RULESYNC_RULES_RELATIVE_DIR_PATH);
54281
- const curatedOutputRoot = (0, node_path.join)(this.inputRoot, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH);
56365
+ async loadRulesyncFilesForRoot(sourceTree) {
56366
+ const treeParent = (0, node_path.dirname)(sourceTree);
56367
+ const treeName = (0, node_path.basename)(sourceTree);
56368
+ const treeRulesDirPath = (0, node_path.join)(treeName, RULES_FEATURE_SUBDIR);
56369
+ const rulesyncOutputRoot = (0, node_path.join)(sourceTree, RULES_FEATURE_SUBDIR);
56370
+ const curatedOutputRoot = (0, node_path.join)(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
54282
56371
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md"))]);
54283
56372
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
54284
56373
  const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
54285
- const localRelativePaths = new Set(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
56374
+ const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
54286
56375
  const curatedFiles = files.filter((file) => (0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`)).map((file) => ({
54287
56376
  file,
54288
56377
  relativeFilePath: (0, node_path.relative)(curatedOutputRoot, file)
54289
- })).filter(({ relativeFilePath }) => !localRelativePaths.has(relativeFilePath));
56378
+ })).filter(({ relativeFilePath }) => {
56379
+ const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
56380
+ if (spellings === void 0) return true;
56381
+ if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
56382
+ artifactKind: "rule",
56383
+ entryNoun: "file",
56384
+ treeDirPath: treeRulesDirPath,
56385
+ curatedSpelling: (0, node_path.join)(".curated", relativeFilePath),
56386
+ localSpellings: spellings
56387
+ }));
56388
+ return false;
56389
+ });
54290
56390
  const selectedFiles = [...localFiles.map((file) => ({
54291
56391
  file,
54292
56392
  sourceRelativeFilePath: (0, node_path.relative)(rulesyncOutputRoot, file),
@@ -54296,25 +56396,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
54296
56396
  sourceRelativeFilePath: (0, node_path.join)(".curated", relativeFilePath),
54297
56397
  relativeFilePath
54298
56398
  }))];
54299
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
54300
- const rulesyncRules = await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
56399
+ this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
56400
+ return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
54301
56401
  checkPathTraversal({
54302
56402
  relativePath: sourceRelativeFilePath,
54303
56403
  intendedRootDir: rulesyncOutputRoot
54304
56404
  });
54305
56405
  const rule = await RulesyncRule.fromFile({
54306
- outputRoot: this.inputRoot,
56406
+ outputRoot: treeParent,
56407
+ relativeDirPath: treeRulesDirPath,
54307
56408
  relativeFilePath: sourceRelativeFilePath
54308
56409
  });
54309
56410
  if (sourceRelativeFilePath === relativeFilePath) return rule;
54310
56411
  return new RulesyncRule({
54311
- outputRoot: this.inputRoot,
54312
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
56412
+ outputRoot: treeParent,
56413
+ relativeDirPath: treeRulesDirPath,
54313
56414
  relativeFilePath,
54314
56415
  frontmatter: rule.getFrontmatter(),
54315
56416
  body: rule.getBody()
54316
56417
  });
54317
56418
  }));
56419
+ }
56420
+ /**
56421
+ * Implementation of abstract method from FeatureProcessor
56422
+ * Load and parse rulesync rule files from every configured input root's
56423
+ * `.rulesync/rules/` directory, merging by relative path so that a rule
56424
+ * with the same target path from a later root replaces the earlier root's
56425
+ * copy (case-insensitive, matching the intra-root collision handling).
56426
+ */
56427
+ async loadRulesyncFiles() {
56428
+ const rulesyncRules = mergeByCaseInsensitiveIdentity({
56429
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
56430
+ identity: (rule) => rule.getRelativeFilePath(),
56431
+ artifactName: "rule",
56432
+ logger: this.logger
56433
+ });
54318
56434
  const factory = this.getFactory(this.toolTarget);
54319
56435
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
54320
56436
  if (targetedRootRules.length === 0 && rulesyncRules.length > 0) this.logger.warn(`No root rulesync rule file found for target '${this.toolTarget}'. Consider adding 'root: true' to one of your rule files in ${RULESYNC_RULES_RELATIVE_DIR_PATH}.`);
@@ -54408,23 +56524,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
54408
56524
  });
54409
56525
  }).filter((rule) => rule.isDeletable());
54410
56526
  };
56527
+ /**
56528
+ * Import counterpart of {@link buildDeletionRulesFromPaths} for the
56529
+ * root-shaped scans (root, legacy roots, and read-only roots), whose
56530
+ * paths all sit at a directory Rulesync resolves from the file itself.
56531
+ */
56532
+ const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
56533
+ const relativeDirPath = resolveRelativeDirPath(filePath);
56534
+ checkPathTraversal({
56535
+ relativePath: relativeDirPath,
56536
+ intendedRootDir: this.outputRoot
56537
+ });
56538
+ return factory.class.fromFile({
56539
+ outputRoot: this.outputRoot,
56540
+ relativeDirPath,
56541
+ relativeFilePath: (0, node_path.basename)(filePath),
56542
+ global: this.global
56543
+ });
56544
+ }));
56545
+ /**
56546
+ * The tool's own root file, as opposed to whichever root
56547
+ * `rootToolRules` ends up resolving. A legacy root reached through
56548
+ * `alternativeRoots` is deliberately not counted here: it is a
56549
+ * hand-authored file Rulesync never writes, so it has folded nothing in,
56550
+ * and in Junie's resolution order it ranks *below* the multi-file layout
56551
+ * that `importOnlyRoots` describes. Gating those roots on it would drop
56552
+ * exactly the files the tool is really reading.
56553
+ *
56554
+ * Resolved once, up front, so the two blocks that need it do not depend
56555
+ * on each other's evaluation order.
56556
+ */
56557
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
54411
56558
  const rootToolRules = await (async () => {
54412
56559
  if (!settablePaths.root) return [];
54413
- const uniqueRootFilePaths = await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
56560
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
54414
56561
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
54415
- return await Promise.all(uniqueRootFilePaths.map((filePath) => {
54416
- const relativeDirPath = resolveRelativeDirPath(filePath);
54417
- checkPathTraversal({
54418
- relativePath: relativeDirPath,
54419
- intendedRootDir: this.outputRoot
54420
- });
54421
- return factory.class.fromFile({
54422
- outputRoot: this.outputRoot,
54423
- relativeFilePath: (0, node_path.basename)(filePath),
54424
- relativeDirPath,
54425
- global: this.global
54426
- });
54427
- }));
56562
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
54428
56563
  })();
54429
56564
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
54430
56565
  const localRootToolRules = await (async () => {
@@ -54436,7 +56571,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54436
56571
  fileName
54437
56572
  }));
54438
56573
  if (!settablePaths.root) return [];
54439
- return await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
56574
+ 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));
54440
56575
  })();
54441
56576
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
54442
56577
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -54512,6 +56647,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
54512
56647
  }));
54513
56648
  })();
54514
56649
  this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
56650
+ const importOnlyToolRules = await (async () => {
56651
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
56652
+ if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
56653
+ const rootFilePath = primaryRootFilePaths[0];
56654
+ const scannedPaths = [];
56655
+ const skippedPaths = [];
56656
+ for (const importOnlyRoot of importOnlyRoots) {
56657
+ const matchedPaths = await findFilesByGlobs((0, node_path.join)(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
56658
+ if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
56659
+ skippedPaths.push(...matchedPaths);
56660
+ continue;
56661
+ }
56662
+ scannedPaths.push(...matchedPaths);
56663
+ }
56664
+ if (skippedPaths.length > 0 && rootFilePath !== void 0) {
56665
+ const skippedNames = skippedPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
56666
+ const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
56667
+ const remainingCount = skippedNames.length - listedNames.length;
56668
+ this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath))} exists, and the tool reads that file exclusively. Delete them once you have checked that content is in the root file, or move it into ${RULESYNC_RULES_RELATIVE_DIR_PATH} if it is not.`);
56669
+ }
56670
+ return await buildImportRulesFromPaths(scannedPaths);
56671
+ })();
56672
+ this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
54515
56673
  const nonRootToolRules = await (async () => {
54516
56674
  if (!settablePaths.nonRoot) return [];
54517
56675
  const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54544,6 +56702,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54544
56702
  })();
54545
56703
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54546
56704
  return [
56705
+ ...importOnlyToolRules,
54547
56706
  ...rootToolRules,
54548
56707
  ...localRootToolRules,
54549
56708
  ...rootMirrorDeletionRules,
@@ -54657,6 +56816,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
54657
56816
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
54658
56817
  */
54659
56818
  async function convertFromTool(params) {
56819
+ resetWarnedOnceMessages();
54660
56820
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
54661
56821
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
54662
56822
  const ctx = params;
@@ -55351,14 +57511,53 @@ function warnUnsupportedTargets(params) {
55351
57511
  }
55352
57512
  }
55353
57513
  /**
55354
- * Check if .rulesync directory exists.
55355
- *
55356
- * The `.rulesync/` directory lives under the *input* root (where source rules
55357
- * are read from), not under any individual output root, so callers always pass
55358
- * `config.getInputRoot()` here.
57514
+ * Inspect every configured input-root path. The first entry is the required
57515
+ * base source tree; later entries are optional overlays and may be absent.
57516
+ * Each existing entry is a rulesync source tree itself (the directory that
57517
+ * directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
57518
+ * directories are valid because delete and check workflows still need to
57519
+ * inspect generated outputs.
55359
57520
  */
55360
- async function checkRulesyncDirExists(params) {
55361
- return fileExists((0, node_path.join)(params.inputRoot, RULESYNC_RELATIVE_DIR_PATH));
57521
+ async function inspectInputRoots(inputRoots) {
57522
+ const existing = [];
57523
+ const missing = [];
57524
+ const invalidOverlays = [];
57525
+ const nonDirectories = /* @__PURE__ */ new Set();
57526
+ for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
57527
+ else {
57528
+ missing.push(root);
57529
+ if (await fileExists(root)) {
57530
+ nonDirectories.add(root);
57531
+ if (index > 0) invalidOverlays.push(root);
57532
+ }
57533
+ }
57534
+ const primaryRoot = inputRoots[0];
57535
+ const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
57536
+ if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
57537
+ const invalidOverlay = invalidOverlays[0];
57538
+ return {
57539
+ existing,
57540
+ missing,
57541
+ message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
57542
+ };
57543
+ }
57544
+ const defaultRoot = (0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
57545
+ if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
57546
+ existing,
57547
+ missing,
57548
+ message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
57549
+ };
57550
+ const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
57551
+ if (nonDirectories.has(primaryRoot)) return {
57552
+ existing,
57553
+ missing,
57554
+ message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
57555
+ };
57556
+ return {
57557
+ existing,
57558
+ missing,
57559
+ message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
57560
+ };
55362
57561
  }
55363
57562
  function dependsOnReachable(byId, from, target) {
55364
57563
  const seen = /* @__PURE__ */ new Set();
@@ -55489,7 +57688,7 @@ async function warnSkillSubagentNameCollisions(params) {
55489
57688
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
55490
57689
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
55491
57690
  const subagentsProcessor = new SubagentsProcessor({
55492
- inputRoot: config.getInputRoot(),
57691
+ inputRoots: config.getInputRoots(),
55493
57692
  toolTarget,
55494
57693
  global,
55495
57694
  logger
@@ -55497,7 +57696,7 @@ async function warnSkillSubagentNameCollisions(params) {
55497
57696
  const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => (0, node_path.basename)(file.getRelativeFilePath(), (0, node_path.extname)(file.getRelativeFilePath()))));
55498
57697
  if (subagentNames.size === 0) continue;
55499
57698
  const skillNames = (await new SkillsProcessor({
55500
- inputRoot: config.getInputRoot(),
57699
+ inputRoots: config.getInputRoots(),
55501
57700
  toolTarget,
55502
57701
  global,
55503
57702
  logger
@@ -55546,6 +57745,8 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55546
57745
  */
55547
57746
  async function generate(params) {
55548
57747
  const { config, logger } = params;
57748
+ resetRootShadowingWarnings({ logger });
57749
+ resetWarnedOnceMessages();
55549
57750
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55550
57751
  toolTarget,
55551
57752
  outputRoot
@@ -55684,7 +57885,7 @@ async function generateRulesCore(params) {
55684
57885
  toolTarget,
55685
57886
  global: config.getGlobal()
55686
57887
  }),
55687
- inputRoot: config.getInputRoot(),
57888
+ inputRoots: config.getInputRoots(),
55688
57889
  toolTarget,
55689
57890
  global: config.getGlobal(),
55690
57891
  simulateCommands: config.getSimulateCommands(),
@@ -55734,7 +57935,7 @@ async function generateIgnoreCore(params) {
55734
57935
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55735
57936
  const processor = new IgnoreProcessor({
55736
57937
  outputRoot,
55737
- inputRoot: config.getInputRoot(),
57938
+ inputRoots: config.getInputRoots(),
55738
57939
  toolTarget,
55739
57940
  global,
55740
57941
  dryRun: config.isPreviewMode(),
@@ -55781,7 +57982,7 @@ async function generateMcpCore(params) {
55781
57982
  toolTarget,
55782
57983
  global: config.getGlobal()
55783
57984
  }),
55784
- inputRoot: config.getInputRoot(),
57985
+ inputRoots: config.getInputRoots(),
55785
57986
  toolTarget,
55786
57987
  global: config.getGlobal(),
55787
57988
  dryRun: config.isPreviewMode(),
@@ -55827,7 +58028,7 @@ async function generateCommandsCore(params) {
55827
58028
  toolTarget,
55828
58029
  global: config.getGlobal()
55829
58030
  }),
55830
- inputRoot: config.getInputRoot(),
58031
+ inputRoots: config.getInputRoots(),
55831
58032
  toolTarget,
55832
58033
  global: config.getGlobal(),
55833
58034
  dryRun: config.isPreviewMode(),
@@ -55874,7 +58075,7 @@ async function generateSubagentsCore(params) {
55874
58075
  toolTarget,
55875
58076
  global: config.getGlobal()
55876
58077
  }),
55877
- inputRoot: config.getInputRoot(),
58078
+ inputRoots: config.getInputRoots(),
55878
58079
  toolTarget,
55879
58080
  global: config.getGlobal(),
55880
58081
  dryRun: config.isPreviewMode(),
@@ -55921,7 +58122,7 @@ async function generateSkillsCore(params) {
55921
58122
  toolTarget,
55922
58123
  global: config.getGlobal()
55923
58124
  }),
55924
- inputRoot: config.getInputRoot(),
58125
+ inputRoots: config.getInputRoots(),
55925
58126
  toolTarget,
55926
58127
  global: config.getGlobal(),
55927
58128
  dryRun: config.isPreviewMode(),
@@ -55966,7 +58167,7 @@ async function generateHooksCore(params) {
55966
58167
  toolTarget,
55967
58168
  global: config.getGlobal()
55968
58169
  }),
55969
- inputRoot: config.getInputRoot(),
58170
+ inputRoots: config.getInputRoots(),
55970
58171
  toolTarget,
55971
58172
  global: config.getGlobal(),
55972
58173
  dryRun: config.isPreviewMode(),
@@ -56008,7 +58209,7 @@ async function generatePermissionsCore(params) {
56008
58209
  toolTarget,
56009
58210
  global: config.getGlobal()
56010
58211
  }),
56011
- inputRoot: config.getInputRoot(),
58212
+ inputRoots: config.getInputRoots(),
56012
58213
  toolTarget,
56013
58214
  global: config.getGlobal(),
56014
58215
  dryRun: config.isPreviewMode(),
@@ -56054,7 +58255,7 @@ async function generateChecksCore(params) {
56054
58255
  toolTarget,
56055
58256
  global: config.getGlobal()
56056
58257
  }),
56057
- inputRoot: config.getInputRoot(),
58258
+ inputRoots: config.getInputRoots(),
56058
58259
  toolTarget,
56059
58260
  global: config.getGlobal(),
56060
58261
  dryRun: config.isPreviewMode(),
@@ -56105,6 +58306,7 @@ function getToolOutputRoot({ config, tool }) {
56105
58306
  */
56106
58307
  async function importFromTool(params) {
56107
58308
  const { config, tool, logger } = params;
58309
+ resetWarnedOnceMessages();
56108
58310
  await assertPluginRootSafe({
56109
58311
  toolTarget: tool,
56110
58312
  outputRoot: getToolOutputRoot({
@@ -56918,12 +59120,6 @@ Object.defineProperty(exports, "checkPathTraversal", {
56918
59120
  return checkPathTraversal;
56919
59121
  }
56920
59122
  });
56921
- Object.defineProperty(exports, "checkRulesyncDirExists", {
56922
- enumerable: true,
56923
- get: function() {
56924
- return checkRulesyncDirExists;
56925
- }
56926
- });
56927
59123
  Object.defineProperty(exports, "convertFromTool", {
56928
59124
  enumerable: true,
56929
59125
  get: function() {
@@ -57020,6 +59216,12 @@ Object.defineProperty(exports, "importFromTool", {
57020
59216
  return importFromTool;
57021
59217
  }
57022
59218
  });
59219
+ Object.defineProperty(exports, "inspectInputRoots", {
59220
+ enumerable: true,
59221
+ get: function() {
59222
+ return inspectInputRoots;
59223
+ }
59224
+ });
57023
59225
  Object.defineProperty(exports, "isPackagingToolTarget", {
57024
59226
  enumerable: true,
57025
59227
  get: function() {
@@ -57044,12 +59246,24 @@ Object.defineProperty(exports, "loadYaml", {
57044
59246
  return loadYaml;
57045
59247
  }
57046
59248
  });
59249
+ Object.defineProperty(exports, "mergeInputRootConfigs", {
59250
+ enumerable: true,
59251
+ get: function() {
59252
+ return mergeInputRootConfigs;
59253
+ }
59254
+ });
57047
59255
  Object.defineProperty(exports, "parseJsonc", {
57048
59256
  enumerable: true,
57049
59257
  get: function() {
57050
59258
  return parseJsonc$8;
57051
59259
  }
57052
59260
  });
59261
+ Object.defineProperty(exports, "pathEscapesRoot", {
59262
+ enumerable: true,
59263
+ get: function() {
59264
+ return pathEscapesRoot;
59265
+ }
59266
+ });
57053
59267
  Object.defineProperty(exports, "readFileContent", {
57054
59268
  enumerable: true,
57055
59269
  get: function() {
@@ -57092,6 +59306,18 @@ Object.defineProperty(exports, "removeTempDirectory", {
57092
59306
  return removeTempDirectory;
57093
59307
  }
57094
59308
  });
59309
+ Object.defineProperty(exports, "resetWarnedOnceMessages", {
59310
+ enumerable: true,
59311
+ get: function() {
59312
+ return resetWarnedOnceMessages;
59313
+ }
59314
+ });
59315
+ Object.defineProperty(exports, "resolveEffectiveInputRoots", {
59316
+ enumerable: true,
59317
+ get: function() {
59318
+ return resolveEffectiveInputRoots;
59319
+ }
59320
+ });
57095
59321
  Object.defineProperty(exports, "resolvePath", {
57096
59322
  enumerable: true,
57097
59323
  get: function() {