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.
@@ -1,6 +1,6 @@
1
1
  import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
- import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
5
  import { parse, printParseErrorCode } from "jsonc-parser";
6
6
  import os from "node:os";
@@ -9,6 +9,7 @@ import { globbySync, isGitIgnoredSync } from "globby";
9
9
  import matter from "gray-matter";
10
10
  import { YAMLException, dump, load } from "js-yaml";
11
11
  import { omit } from "es-toolkit/object";
12
+ import { constants } from "node:fs";
12
13
  import { createHash } from "node:crypto";
13
14
  import { isDeepStrictEqual } from "node:util";
14
15
  import * as smolToml from "smol-toml";
@@ -113,11 +114,18 @@ const { join: join$1 } = posix;
113
114
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
114
115
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
115
116
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
116
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "rules");
117
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RULES_RELATIVE_DIR_PATH, ".curated");
118
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "commands");
119
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "subagents");
120
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "checks");
117
+ const RULES_FEATURE_SUBDIR = "rules";
118
+ const CURATED_RULES_FEATURE_SUBDIR = join$1(RULES_FEATURE_SUBDIR, ".curated");
119
+ const COMMANDS_FEATURE_SUBDIR = "commands";
120
+ const SUBAGENTS_FEATURE_SUBDIR = "subagents";
121
+ const CHECKS_FEATURE_SUBDIR = "checks";
122
+ const SKILLS_FEATURE_SUBDIR = "skills";
123
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$1(SKILLS_FEATURE_SUBDIR, ".curated");
124
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
125
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
126
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
127
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
128
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
121
129
  const RULESYNC_MCP_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
122
130
  const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
123
131
  const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
@@ -128,8 +136,8 @@ const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
128
136
  const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
129
137
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
130
138
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
131
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "skills");
132
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_SKILLS_RELATIVE_DIR_PATH, ".curated");
139
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
140
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
133
141
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
134
142
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
135
143
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -463,9 +471,22 @@ function isEnvTest() {
463
471
  }
464
472
  //#endregion
465
473
  //#region src/utils/file.ts
474
+ /**
475
+ * Whether a relative path leads out of the root it is relative to. Matching
476
+ * whole segments matters: a directory really named `..cache` relatively
477
+ * resolves to `..cache/file`, which a prefix test would report as an escape.
478
+ */
466
479
  function pathEscapesRoot(relativePath) {
467
480
  return relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath);
468
481
  }
482
+ /** Whether a single path segment is a hidden (dot-prefixed) name. */
483
+ function isHiddenPathSegment(segment) {
484
+ return segment.startsWith(".") && segment !== "." && segment !== "..";
485
+ }
486
+ /** Split a path on both separators, so one predicate serves either platform. */
487
+ function splitPathSegments(filePath) {
488
+ return filePath.split(/[/\\]/);
489
+ }
469
490
  async function assertWritablePathInsideRoot(params) {
470
491
  const { rootPath, targetPath } = params;
471
492
  let existingPath = targetPath;
@@ -696,8 +717,45 @@ async function listDirectoryFiles(dir) {
696
717
  return [];
697
718
  }
698
719
  }
720
+ /** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
721
+ function countHiddenSegments(filePath) {
722
+ return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
723
+ }
724
+ /**
725
+ * The real file a path denotes, posix-separated so it compares against the globby results
726
+ * that produce it. Two paths share an identity when they resolve to the very same file --
727
+ * a link beside its target, a link into a shared tree, or a cycle that walks back into an
728
+ * ancestor and yields the same file forty levels down.
729
+ */
730
+ async function realFileIdentity(filePath) {
731
+ try {
732
+ return toPosixPath(await realpath(filePath));
733
+ } catch {
734
+ return toPosixPath(filePath);
735
+ }
736
+ }
737
+ /**
738
+ * Pick the one path that represents a file among the paths that resolve to it.
739
+ *
740
+ * The path that walked through no link at all wins outright: it is already the real one,
741
+ * so it equals the file's identity. That keeps the real location of a file as the path
742
+ * callers see, rather than an alias that happens to sort first -- a directory link named
743
+ * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
744
+ * `sub/note.md` with the same file reached back through the cycle.
745
+ * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
746
+ * named one represents the entry rather than a hidden alias that a hidden-entry rule may
747
+ * then drop, taking the named path's content with it. `candidates` arrives in sorted
748
+ * order, so ties keep the first one deterministically.
749
+ */
750
+ function chooseRepresentative(candidates, identity) {
751
+ return candidates.reduce((best, candidate) => {
752
+ if (toPosixPath(best) === identity) return best;
753
+ if (toPosixPath(candidate) === identity) return candidate;
754
+ return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
755
+ });
756
+ }
699
757
  async function findFilesByGlobs(globs, options = {}) {
700
- const { type = "all", followSymbolicLinks = true, ignore } = options;
758
+ const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
701
759
  const globbyOptions = type === "file" ? {
702
760
  onlyFiles: true,
703
761
  onlyDirectories: false
@@ -712,23 +770,18 @@ async function findFilesByGlobs(globs, options = {}) {
712
770
  const results = globbySync(normalizedGlobs, {
713
771
  absolute: true,
714
772
  followSymbolicLinks,
773
+ dot,
715
774
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
716
775
  ...globbyOptions
717
776
  });
718
- const seenRealPaths = /* @__PURE__ */ new Set();
719
- const deduped = [];
777
+ const candidatesByFile = /* @__PURE__ */ new Map();
720
778
  for (const result of results.toSorted()) {
721
- let realResult;
722
- try {
723
- realResult = await realpath(result);
724
- } catch {
725
- realResult = result;
726
- }
727
- if (seenRealPaths.has(realResult)) continue;
728
- seenRealPaths.add(realResult);
729
- deduped.push(result);
779
+ const identity = await realFileIdentity(result);
780
+ const candidates = candidatesByFile.get(identity);
781
+ if (candidates === void 0) candidatesByFile.set(identity, [result]);
782
+ else candidates.push(result);
730
783
  }
731
- return deduped;
784
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
732
785
  }
733
786
  async function removeDirectory(dirPath) {
734
787
  if ([
@@ -887,6 +940,25 @@ var CLIError = class extends Error {
887
940
  }
888
941
  };
889
942
  //#endregion
943
+ //#region src/utils/warned-once.ts
944
+ /**
945
+ * The messages a once-per-run warning has already emitted in this process.
946
+ * This lives in its own module, free of imports, so the vitest setup file can
947
+ * clear it between tests without pulling `logger.js` into every test's module
948
+ * graph (which would defeat the module mocks some of those tests install).
949
+ */
950
+ const warnedOnceMessages = /* @__PURE__ */ new Set();
951
+ /** Whether `message` has not been emitted yet; records it when it has not. */
952
+ function claimWarnOnce(message) {
953
+ if (warnedOnceMessages.has(message)) return false;
954
+ warnedOnceMessages.add(message);
955
+ return true;
956
+ }
957
+ /** Forget which warnings were already emitted, so each test starts silent. */
958
+ function resetWarnedOnceMessages() {
959
+ warnedOnceMessages.clear();
960
+ }
961
+ //#endregion
890
962
  //#region src/utils/logger.ts
891
963
  /**
892
964
  * Base class for shared verbose/silent state and configuration logic
@@ -1039,6 +1111,17 @@ const fallbackLogger = new ConsoleLogger();
1039
1111
  function warnWithFallback(logger, message) {
1040
1112
  (logger ?? fallbackLogger).warn(message);
1041
1113
  }
1114
+ /**
1115
+ * Emit a warning at most once per run. A single `generate` reads the same source
1116
+ * file once per enabled tool target, so a warning that describes the source
1117
+ * rather than the target would otherwise be printed a dozen identical times.
1118
+ * Diagnostics that name the file they are about qualify; anything whose text
1119
+ * varies with what the user should do next does not.
1120
+ */
1121
+ function warnOnceWithFallback(logger, message) {
1122
+ if (!claimWarnOnce(message)) return;
1123
+ warnWithFallback(logger, message);
1124
+ }
1042
1125
  //#endregion
1043
1126
  //#region src/utils/validation.ts
1044
1127
  /**
@@ -1117,6 +1200,7 @@ const ConfigParamsSchema = z.object({
1117
1200
  dryRun: optional(z.boolean()),
1118
1201
  check: optional(z.boolean()),
1119
1202
  inputRoot: optional(z.string()),
1203
+ inputRoots: optional(z.array(z.string()).check(minLength(1, "inputRoots must be non-empty"))),
1120
1204
  sources: optional(z.array(SourceEntrySchema))
1121
1205
  });
1122
1206
  z.partial(ConfigParamsSchema);
@@ -1129,14 +1213,39 @@ z.required(ConfigParamsSchema);
1129
1213
  * Normalizes the configuration file location to an absolute path.
1130
1214
  *
1131
1215
  * `ConfigResolver` always supplies the path it actually loaded; the fallback
1132
- * only covers direct programmatic construction, where the conventional
1133
- * location next to the input root is the best guess.
1216
+ * only covers direct programmatic construction. `anchorDir` is the directory
1217
+ * the config file lives next to for the default `.rulesync/` layout this
1218
+ * is the parent of the primary source tree.
1134
1219
  */
1135
- function normalizeConfigFilePath({ configFilePath, inputRoot }) {
1136
- if (configFilePath === void 0) return join(inputRoot, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1220
+ function normalizeConfigFilePath({ configFilePath, anchorDir }) {
1221
+ if (configFilePath === void 0) return join(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1137
1222
  return isAbsolute(configFilePath) ? configFilePath : resolve(configFilePath);
1138
1223
  }
1139
1224
  /**
1225
+ * Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
1226
+ * neither) to the canonical non-empty tuple of absolute paths that
1227
+ * `Config` stores. Relative entries are resolved against the current
1228
+ * working directory at call time.
1229
+ *
1230
+ * Semantics (post-refactor):
1231
+ * - Each entry in `inputRoots` is a rulesync **source tree** (the directory
1232
+ * that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
1233
+ * `.rulesync/` join is applied.
1234
+ * - The legacy singular `inputRoot` is a shorthand for "parent of the
1235
+ * default `.rulesync/` source tree", and is expanded to
1236
+ * `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
1237
+ * the ONLY place `.rulesync` is appended by convention.
1238
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1239
+ * so existing projects with a single `.rulesync/` tree keep working
1240
+ * unchanged.
1241
+ *
1242
+ * Callers must have already run `assertInputRootFieldsExclusive`.
1243
+ */
1244
+ function normalizeInputRoots({ inputRoot, inputRoots }) {
1245
+ const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [join(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => isAbsolute(entry) ? entry : resolve(entry));
1246
+ return [resolved[0], ...resolved.slice(1)];
1247
+ }
1248
+ /**
1140
1249
  * Conflicting target pairs that cannot be used together.
1141
1250
  * Exported so `rulesync doctor` can report the same conflicts as diagnostics
1142
1251
  * without duplicating the list.
@@ -1173,6 +1282,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
1173
1282
  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.");
1174
1283
  };
1175
1284
  /**
1285
+ * Rejects a single user-authored config file (or a single programmatic
1286
+ * construction) that defines both `inputRoot` and `inputRoots` — the two
1287
+ * fields express the same setting at singular vs. list level and cannot
1288
+ * be combined within one file without ambiguity.
1289
+ *
1290
+ * The check is intentionally per-file: base and local config files can each
1291
+ * be valid in isolation and merge into a state where both survive, and the
1292
+ * resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
1293
+ * Only a single file declaring both is a genuine authoring error.
1294
+ */
1295
+ const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
1296
+ 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.");
1297
+ };
1298
+ /**
1299
+ * Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
1300
+ * selects the conventional default, while an empty list has no meaningful
1301
+ * source-tree semantics and must not be treated as an absent override.
1302
+ */
1303
+ const assertInputRootsNonEmpty = ({ inputRoots }) => {
1304
+ if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
1305
+ };
1306
+ /**
1176
1307
  * Normalizes a post-resolution `ConfigParams` input by rejecting the case
1177
1308
  * where both `targets` and `features` are undefined — a degenerate state
1178
1309
  * that would silently produce a no-op config (no targets, no features).
@@ -1208,14 +1339,35 @@ var Config = class Config {
1208
1339
  gitignoreDestination;
1209
1340
  dryRun;
1210
1341
  check;
1211
- inputRoot;
1342
+ /**
1343
+ * Ordered, absolute-path list of rulesync source trees. Each entry is a
1344
+ * source tree itself — the directory that directly contains `rules/`,
1345
+ * `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
1346
+ *
1347
+ * Always non-empty by construction — the constructor either normalizes
1348
+ * an `inputRoot`/`inputRoots` input or falls back to a single-element
1349
+ * list containing `join(<cwd>, ".rulesync")`.
1350
+ *
1351
+ * `inputRoot` (singular) is a deprecated backward-compatibility alias
1352
+ * that expands to `[join(inputRoot, ".rulesync")]`.
1353
+ *
1354
+ * Typed as a non-empty tuple so the one internal caller that legitimately
1355
+ * needs "the primary root" (`normalizeConfigFilePath` fallback) can index
1356
+ * `[0]` without a runtime null-check.
1357
+ */
1358
+ inputRoots;
1212
1359
  configFilePath;
1213
1360
  sources;
1214
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
1361
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
1215
1362
  assertTargetsFeaturesExclusive({
1216
1363
  targets,
1217
1364
  features
1218
1365
  });
1366
+ assertInputRootFieldsExclusive({
1367
+ inputRoot,
1368
+ inputRoots
1369
+ });
1370
+ assertInputRootsNonEmpty({ inputRoots });
1219
1371
  assertTargetsOrFeaturesProvided({
1220
1372
  targets,
1221
1373
  features
@@ -1243,10 +1395,13 @@ var Config = class Config {
1243
1395
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
1244
1396
  this.dryRun = dryRun ?? false;
1245
1397
  this.check = check ?? false;
1246
- this.inputRoot = inputRoot === void 0 ? process.cwd() : isAbsolute(inputRoot) ? inputRoot : resolve(inputRoot);
1398
+ this.inputRoots = normalizeInputRoots({
1399
+ inputRoot,
1400
+ inputRoots
1401
+ });
1247
1402
  this.configFilePath = normalizeConfigFilePath({
1248
1403
  configFilePath,
1249
- inputRoot: this.inputRoot
1404
+ anchorDir: dirname(this.inputRoots[0])
1250
1405
  });
1251
1406
  this.sources = sources ?? [];
1252
1407
  }
@@ -1430,16 +1585,22 @@ var Config = class Config {
1430
1585
  return this.check;
1431
1586
  }
1432
1587
  /**
1433
- * Returns the directory containing the `.rulesync/` source files. The value
1434
- * is always an absolute path captured at config-construction time, so this
1435
- * accessor is pure and never depends on a live `process.cwd()` read.
1588
+ * Returns the ordered list of rulesync source trees. Each entry is the
1589
+ * source tree itself the directory that directly contains `rules/`,
1590
+ * `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
1591
+ * config-construction time, so this accessor is pure and never depends on
1592
+ * a live `process.cwd()` read.
1436
1593
  *
1437
- * When no `inputRoot` was supplied to the constructor, `process.cwd()` is
1438
- * snapshotted once during construction. When a relative `inputRoot` is
1439
- * supplied, it is resolved to absolute against the construction-time cwd.
1594
+ * The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
1595
+ * was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
1596
+ * during construction. The first entry is the required base source tree.
1597
+ * Later entries are optional overlays and may be absent; when present, they
1598
+ * take precedence when the same relative source path exists in more than
1599
+ * one root (see per-feature merge policies in the processor
1600
+ * `loadRulesync*` methods).
1440
1601
  */
1441
- getInputRoot() {
1442
- return this.inputRoot;
1602
+ getInputRoots() {
1603
+ return this.inputRoots;
1443
1604
  }
1444
1605
  /**
1445
1606
  * Returns the absolute path of the configuration file this config was
@@ -1480,6 +1641,7 @@ const getDefaults = () => ({
1480
1641
  dryRun: false,
1481
1642
  check: false,
1482
1643
  inputRoot: void 0,
1644
+ inputRoots: void 0,
1483
1645
  sources: []
1484
1646
  });
1485
1647
  const loadConfigFromFile = async (filePath) => {
@@ -1491,8 +1653,23 @@ const loadConfigFromFile = async (filePath) => {
1491
1653
  targets: configParams.targets,
1492
1654
  features: configParams.features
1493
1655
  });
1656
+ try {
1657
+ assertInputRootFieldsExclusive({
1658
+ inputRoot: configParams.inputRoot,
1659
+ inputRoots: configParams.inputRoots
1660
+ });
1661
+ } catch (error) {
1662
+ const detail = error instanceof Error ? error.message : String(error);
1663
+ throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
1664
+ }
1494
1665
  return configParams;
1495
1666
  };
1667
+ function mergeInputRootConfigs({ baseConfig, localConfig }) {
1668
+ return {
1669
+ inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1670
+ inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
1671
+ };
1672
+ }
1496
1673
  const mergeConfigs = (baseConfig, localConfig) => {
1497
1674
  return {
1498
1675
  targets: localConfig.targets ?? baseConfig.targets,
@@ -1510,7 +1687,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
1510
1687
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
1511
1688
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
1512
1689
  check: localConfig.check ?? baseConfig.check,
1513
- inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1690
+ ...mergeInputRootConfigs({
1691
+ baseConfig,
1692
+ localConfig
1693
+ }),
1514
1694
  sources: localConfig.sources ?? baseConfig.sources
1515
1695
  };
1516
1696
  };
@@ -1539,13 +1719,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
1539
1719
  }
1540
1720
  }
1541
1721
  /**
1542
- * Resolve the effective `global` flag. When an `inputRoot` is in play the user
1543
- * is decoupling source from output, so a config-file `global: true` is dropped
1544
- * (unless the caller also explicitly passes `global`); a warning is emitted in
1722
+ * Resolve the effective `global` flag. When an input root (singular
1723
+ * `inputRoot` or plural `inputRoots`) is in play the user is decoupling
1724
+ * source from output, so a config-file `global: true` is dropped (unless
1725
+ * the caller also explicitly passes `global`); a warning is emitted in
1545
1726
  * that case. Returns the resolved boolean `global`.
1546
1727
  */
1547
1728
  function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
1548
- 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).`);
1729
+ 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).`);
1549
1730
  return pick({
1550
1731
  cli: global,
1551
1732
  file: resolvedInputRoot !== void 0 ? false : configByFile.global,
@@ -1569,17 +1750,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
1569
1750
  resolvedTargets: userProvidedTargets ?? getDefaults().targets
1570
1751
  };
1571
1752
  }
1753
+ /**
1754
+ * Resolve the effective, non-empty, absolute-path list of source-tree roots
1755
+ * by applying CLI > file > default precedence and preferring `inputRoots`
1756
+ * over `inputRoot` when both survive the base+local merge. Duplicates (after
1757
+ * normalization to absolute paths) are removed silently so overlapping
1758
+ * base/local declarations do not double-count the same tree.
1759
+ *
1760
+ * Semantics (post-refactor):
1761
+ * - `inputRoots` entries are the source trees themselves (each holds
1762
+ * `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
1763
+ * unchanged.
1764
+ * - `inputRoot` (legacy singular) is a shorthand for "parent of the
1765
+ * `.rulesync/` source tree" and is expanded to `join(inputRoot,
1766
+ * ".rulesync")` before it hits any consumer.
1767
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1768
+ * so existing projects keep working unchanged.
1769
+ *
1770
+ * When both the merged file config has `inputRoots` and the CLI supplied
1771
+ * `inputRoot` (or vice versa), CLI wins outright — matching how every
1772
+ * other field is resolved. When only the file config supplies both, the
1773
+ * plural wins over the singular and the drop is logged at debug level.
1774
+ */
1775
+ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
1776
+ let source;
1777
+ let field;
1778
+ if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
1779
+ source = cliInputRoots;
1780
+ field = "inputRoots";
1781
+ } else if (cliInputRoot !== void 0) {
1782
+ source = [join(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1783
+ field = "inputRoot";
1784
+ } else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
1785
+ source = configByFile.inputRoots;
1786
+ field = "inputRoots";
1787
+ 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.`);
1788
+ } else if (configByFile.inputRoot !== void 0) {
1789
+ source = [join(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1790
+ field = "inputRoot";
1791
+ } else source = [join(cwd, RULESYNC_RELATIVE_DIR_PATH)];
1792
+ const candidates = source.map((entry) => resolve(cwd, entry));
1793
+ const seen = /* @__PURE__ */ new Set();
1794
+ const resolved = [];
1795
+ for (const absolute of candidates) {
1796
+ if (seen.has(absolute)) continue;
1797
+ seen.add(absolute);
1798
+ resolved.push(absolute);
1799
+ }
1800
+ return {
1801
+ inputRoots: [resolved[0], ...resolved.slice(1)],
1802
+ candidates,
1803
+ field
1804
+ };
1805
+ }
1572
1806
  var ConfigResolver = class {
1573
- static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
1807
+ static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
1574
1808
  const cwd = resolve(process.cwd());
1809
+ assertInputRootFieldsExclusive({
1810
+ inputRoot,
1811
+ inputRoots
1812
+ });
1813
+ assertInputRootsNonEmpty({ inputRoots });
1575
1814
  if (inputRoot !== void 0) validateOutputRoot(inputRoot);
1576
- const validatedConfigPath = resolvePath(configPath, resolve(inputRoot ?? cwd));
1815
+ if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
1816
+ const cliConfigAnchor = inputRoot;
1817
+ const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
1818
+ const validatedConfigPath = resolvePath(configPath, resolve(cliConfigAnchor ?? cwd));
1577
1819
  const baseConfig = await loadConfigFromFile(validatedConfigPath);
1578
1820
  const configDir = dirname(validatedConfigPath);
1579
1821
  const localConfigPath = join(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
1580
1822
  const localConfig = await loadConfigFromFile(localConfigPath);
1581
1823
  const configByFile = mergeConfigs(baseConfig, localConfig);
1582
- if (inputRoot === void 0 && configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1824
+ if (!hasCliInputRootOverride) {
1825
+ if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1826
+ if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
1827
+ }
1583
1828
  assertMergedTargetsFeaturesExclusive({
1584
1829
  configByFile,
1585
1830
  validatedConfigPath,
@@ -1605,10 +1850,16 @@ var ConfigResolver = class {
1605
1850
  silent: resolvedSilent
1606
1851
  });
1607
1852
  }
1608
- const resolvedInputRoot = inputRoot ?? configByFile.inputRoot;
1853
+ const resolvedInputRoots = resolveEffectiveInputRoots({
1854
+ cliInputRoot: inputRoot,
1855
+ cliInputRoots: inputRoots,
1856
+ configByFile,
1857
+ cwd,
1858
+ logger
1859
+ }).inputRoots;
1609
1860
  const resolvedGlobal = resolveGlobal({
1610
1861
  logger,
1611
- resolvedInputRoot,
1862
+ resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
1612
1863
  global,
1613
1864
  configByFile,
1614
1865
  validatedConfigPath
@@ -1672,7 +1923,7 @@ var ConfigResolver = class {
1672
1923
  file: configByFile.check,
1673
1924
  fallback: getDefaults().check
1674
1925
  }),
1675
- inputRoot: resolvedInputRoot !== void 0 ? resolve(resolvedInputRoot) : cwd,
1926
+ inputRoots: resolvedInputRoots,
1676
1927
  configFilePath: validatedConfigPath,
1677
1928
  sources: configByFile.sources ?? getDefaults().sources,
1678
1929
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
@@ -1815,8 +2066,7 @@ var AiFile = class {
1815
2066
  const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
1816
2067
  const resolvedFull = resolve(fullPath);
1817
2068
  const resolvedBase = resolve(this.outputRoot);
1818
- const rel = relative(resolvedBase, resolvedFull);
1819
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
2069
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
1820
2070
  return fullPath;
1821
2071
  }
1822
2072
  getFileContent() {
@@ -1878,6 +2128,27 @@ var RulesyncFile = class extends AiFile {
1878
2128
  }
1879
2129
  };
1880
2130
  //#endregion
2131
+ //#region src/utils/control-characters.ts
2132
+ /**
2133
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
2134
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
2135
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
2136
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
2137
+ * settings file must never reach the terminal with these intact: they let the
2138
+ * text forge log lines, reorder what is printed around them, or inject escape
2139
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
2140
+ * neutral characters beside them, so they go too — a diagnostic line is not the
2141
+ * place to preserve the typography of a right-to-left name.
2142
+ */
2143
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
2144
+ /**
2145
+ * Removes every control character from `text` so it is safe to splice into a
2146
+ * log line or other terminal output.
2147
+ */
2148
+ function stripControlCharacters(text) {
2149
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
2150
+ }
2151
+ //#endregion
1881
2152
  //#region src/utils/type-guards.ts
1882
2153
  /**
1883
2154
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -2001,7 +2272,7 @@ function parseFrontmatter(content, filePath) {
2001
2272
  let body;
2002
2273
  let hasFrontmatter;
2003
2274
  try {
2004
- const result = matter(content);
2275
+ const result = matter(content, {});
2005
2276
  frontmatter = result.data;
2006
2277
  body = result.content;
2007
2278
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
@@ -2015,6 +2286,104 @@ function parseFrontmatter(content, filePath) {
2015
2286
  hasFrontmatter
2016
2287
  };
2017
2288
  }
2289
+ /**
2290
+ * A top-level `key: value` entry. Nested entries are left alone deliberately:
2291
+ * the repair below rewrites a value's meaning, and the failure it exists for —
2292
+ * an unquoted sentence with a colon in it — is a `description`, which is always
2293
+ * top-level.
2294
+ */
2295
+ const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
2296
+ /** A plain scalar that already starts as some other YAML construct. */
2297
+ const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
2298
+ /**
2299
+ * Cut a plain scalar at its inline comment — whitespace followed by `#`.
2300
+ *
2301
+ * This has to happen before anything else looks at the value, or a line such as
2302
+ * `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
2303
+ * back quoted with the comment inside it, which for a list of tool permissions
2304
+ * would grant what the comment had disabled. Scanning by hand rather than with
2305
+ * `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
2306
+ * after it backtracks from every starting position, and a value padded with a
2307
+ * megabyte of spaces takes minutes to reject. This is linear in the value.
2308
+ */
2309
+ function stripInlineComment(rawValue) {
2310
+ for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
2311
+ return rawValue.trimEnd();
2312
+ }
2313
+ function repairFrontmatterLine(line) {
2314
+ const unchanged = {
2315
+ line,
2316
+ droppedComment: false
2317
+ };
2318
+ const carriageReturn = line.endsWith("\r") ? "\r" : "";
2319
+ const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
2320
+ const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
2321
+ if (!match) return unchanged;
2322
+ const [, key = "", rawValue = ""] = match;
2323
+ const value = stripInlineComment(rawValue);
2324
+ if (value === "") return unchanged;
2325
+ if (!/:(?:\s|$)/.test(value)) return unchanged;
2326
+ if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
2327
+ return {
2328
+ line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
2329
+ droppedComment: value !== rawValue.trimEnd()
2330
+ };
2331
+ }
2332
+ /**
2333
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
2334
+ * return `undefined` when there is nothing to repair. Only the frontmatter
2335
+ * block is rewritten; the body is passed through untouched.
2336
+ */
2337
+ function repairMalformedFrontmatterYaml(content) {
2338
+ const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
2339
+ if (!opening) return;
2340
+ const blockStart = opening[0].length;
2341
+ const closing = /\r?\n---/.exec(content.slice(blockStart));
2342
+ if (!closing) return;
2343
+ const blockEnd = blockStart + closing.index;
2344
+ const block = content.slice(blockStart, blockEnd);
2345
+ const repairedLines = block.split("\n").map(repairFrontmatterLine);
2346
+ const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
2347
+ if (repairedBlock === block) return;
2348
+ return {
2349
+ content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
2350
+ droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
2351
+ };
2352
+ }
2353
+ /**
2354
+ * Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
2355
+ *
2356
+ * Files authored for another client routinely carry YAML that only that
2357
+ * client's parser accepts — `description: Use this skill when: the user asks
2358
+ * about PDFs` is the case the Agent Skills client guide names. Without a retry
2359
+ * such a file is not merely reported, it is dropped: the lenient skill import
2360
+ * catches the parse error and skips the whole skill. The retry is deliberately
2361
+ * narrow — one pass, top-level entries only, and the original error is what
2362
+ * surfaces if it does not help, so a genuinely broken file still fails with the
2363
+ * message that describes what is actually wrong with it. A file with no closing
2364
+ * `---`, or one whose opening fence carries a language tag, is not repaired at
2365
+ * all: neither is a frontmatter block gray-matter would have read.
2366
+ *
2367
+ * @see https://agentskills.io/client-implementation/adding-skills-support
2368
+ */
2369
+ function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
2370
+ try {
2371
+ return parseFrontmatter(content, filePath);
2372
+ } catch (error) {
2373
+ const repaired = repairMalformedFrontmatterYaml(content);
2374
+ if (repaired === void 0) throw error;
2375
+ let result;
2376
+ try {
2377
+ result = parseFrontmatter(repaired.content, filePath);
2378
+ } catch {
2379
+ throw error;
2380
+ }
2381
+ if (options.quiet === true) return result;
2382
+ const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
2383
+ 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.`);
2384
+ return result;
2385
+ }
2386
+ }
2018
2387
  //#endregion
2019
2388
  //#region src/features/checks/rulesync-check.ts
2020
2389
  const RulesyncCheckFrontmatterSchema = z.looseObject({
@@ -2072,8 +2441,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2072
2441
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2073
2442
  };
2074
2443
  }
2075
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2076
- const filePath = join(outputRoot, RULESYNC_CHECKS_RELATIVE_DIR_PATH, relativeFilePath);
2444
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2445
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
2446
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
2077
2447
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
2078
2448
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
2079
2449
  const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
@@ -2081,7 +2451,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2081
2451
  const filename = basename(relativeFilePath);
2082
2452
  return new RulesyncCheck({
2083
2453
  outputRoot,
2084
- relativeDirPath: this.getSettablePaths().relativeDirPath,
2454
+ relativeDirPath: dirPath,
2085
2455
  relativeFilePath: filename,
2086
2456
  frontmatter: result.data,
2087
2457
  body: content.trim()
@@ -2152,8 +2522,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2152
2522
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2153
2523
  };
2154
2524
  }
2155
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2156
- const filePath = join(outputRoot, RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath);
2525
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2526
+ const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
2527
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
2157
2528
  const fileContent = await readFileContent(filePath);
2158
2529
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
2159
2530
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
@@ -2161,7 +2532,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2161
2532
  if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
2162
2533
  return new RulesyncCommand({
2163
2534
  outputRoot,
2164
- relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
2535
+ relativeDirPath: dirPath,
2165
2536
  relativeFilePath,
2166
2537
  frontmatter: result.data,
2167
2538
  body: content.trim(),
@@ -3550,8 +3921,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
3550
3921
  };
3551
3922
  /**
3552
3923
  * Map Mistral Vibe snake_case event names to canonical camelCase.
3924
+ *
3925
+ * The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
3926
+ * strict `HookType` enum rejects a file that still uses them, so such a file is
3927
+ * already dead on disk; reading it here and emitting the renamed spelling is
3928
+ * what repairs it, whereas leaving the old name unmapped would route the hook
3929
+ * into a tool override block and lose the event.
3553
3930
  */
3554
- const VIBE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k]));
3931
+ const VIBE_TO_CANONICAL_EVENT_NAMES = {
3932
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
3933
+ before_tool: "preToolUse",
3934
+ after_tool: "postToolUse",
3935
+ post_agent_turn: "stop"
3936
+ };
3555
3937
  /**
3556
3938
  * Map canonical camelCase event names to Qwen Code PascalCase.
3557
3939
  *
@@ -3772,15 +4154,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
3772
4154
  error: null
3773
4155
  };
3774
4156
  }
3775
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
4157
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
3776
4158
  const paths = RulesyncHooks.getSettablePaths();
4159
+ const overrideDirPath = relativeDirPath;
3777
4160
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
3778
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4161
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4162
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
3779
4163
  if (!await fileExists(filePath)) continue;
3780
4164
  const fileContent = await readFileContent(filePath);
3781
4165
  return new RulesyncHooks({
3782
4166
  outputRoot,
3783
- relativeDirPath: candidate.relativeDirPath,
4167
+ relativeDirPath: candidateDirPath,
3784
4168
  relativeFilePath: candidate.relativeFilePath,
3785
4169
  fileContent,
3786
4170
  validate
@@ -3807,21 +4191,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3807
4191
  relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
3808
4192
  relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
3809
4193
  },
3810
- legacy: {
4194
+ legacy: [{
3811
4195
  relativeDirPath: ".",
3812
4196
  relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
3813
- }
4197
+ }]
3814
4198
  };
3815
4199
  }
3816
- static async fromFile({ outputRoot = process.cwd() } = {}) {
4200
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
3817
4201
  const paths = this.getSettablePaths();
3818
- const recommendedPath = join(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
3819
- const legacyPath = join(outputRoot, paths.legacy.relativeDirPath, paths.legacy.relativeFilePath);
4202
+ const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
4203
+ const recommendedPath = join(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
4204
+ const [legacy] = paths.legacy;
4205
+ const legacyPath = join(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
3820
4206
  if (await fileExists(recommendedPath)) {
3821
4207
  const fileContent = await readFileContent(recommendedPath);
3822
4208
  return new RulesyncIgnore({
3823
4209
  outputRoot,
3824
- relativeDirPath: paths.recommended.relativeDirPath,
4210
+ relativeDirPath: recommendedDirPath,
3825
4211
  relativeFilePath: paths.recommended.relativeFilePath,
3826
4212
  fileContent
3827
4213
  });
@@ -3830,15 +4216,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3830
4216
  const fileContent = await readFileContent(legacyPath);
3831
4217
  return new RulesyncIgnore({
3832
4218
  outputRoot,
3833
- relativeDirPath: paths.legacy.relativeDirPath,
3834
- relativeFilePath: paths.legacy.relativeFilePath,
4219
+ relativeDirPath: legacy.relativeDirPath,
4220
+ relativeFilePath: legacy.relativeFilePath,
3835
4221
  fileContent
3836
4222
  });
3837
4223
  }
3838
4224
  const fileContent = await readFileContent(recommendedPath);
3839
4225
  return new RulesyncIgnore({
3840
4226
  outputRoot,
3841
- relativeDirPath: paths.recommended.relativeDirPath,
4227
+ relativeDirPath: recommendedDirPath,
3842
4228
  relativeFilePath: paths.recommended.relativeFilePath,
3843
4229
  fileContent
3844
4230
  });
@@ -3994,6 +4380,118 @@ const RulesyncMcpFileSchema = z.looseObject({
3994
4380
  warp: z.optional(toolScopedMcpSchema),
3995
4381
  zed: z.optional(toolScopedMcpSchema)
3996
4382
  });
4383
+ /**
4384
+ * The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
4385
+ * Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
4386
+ * from the schema — every tool-scoped block declared above is treated as a
4387
+ * "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
4388
+ * (including `$schema` and top-level Kimi Code timeout fields) is replaced
4389
+ * atomically.
4390
+ */
4391
+ const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
4392
+ /**
4393
+ * Return the first candidate path (recommended, then legacy variants) that
4394
+ * exists under `outputRoot`, or `undefined` when none is present. Shared
4395
+ * between `fromFile` (single-root) and `fromRoots` (multi-root) so both
4396
+ * paths honour the same intra-root resolution order.
4397
+ *
4398
+ * When `overrideDirPath` is provided it replaces the candidates'
4399
+ * class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
4400
+ * caller can point at a non-default source tree (e.g. `.rulesync.local/`).
4401
+ * Also returned is the effective `relativeDirPath` for the winning
4402
+ * candidate so the caller can reconstruct a `RulesyncMcp` with matching
4403
+ * anchor fields.
4404
+ */
4405
+ async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
4406
+ for (const candidate of getRulesyncSourceCandidates({ paths })) {
4407
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4408
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
4409
+ if (await fileExists(filePath)) return {
4410
+ filePath,
4411
+ candidate: {
4412
+ relativeDirPath: candidateDirPath,
4413
+ relativeFilePath: candidate.relativeFilePath
4414
+ }
4415
+ };
4416
+ }
4417
+ }
4418
+ /**
4419
+ * Merge two parsed MCP JSON objects with the one-level policy from the
4420
+ * inputRoots plan: the top-level `mcpServers` map and each
4421
+ * `<toolname>.mcpServers` sub-map are merged by server name (later wins per
4422
+ * key). Every other value — individual server configs, other top-level keys
4423
+ * — is replaced atomically. This keeps the merge predictable: an overlay can
4424
+ * add or replace whole shared servers, but a partial patch of one server's
4425
+ * `args`/`env` is deliberately not supported.
4426
+ */
4427
+ function getRecordField({ value, path }) {
4428
+ if (value === void 0) return {};
4429
+ if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
4430
+ return value;
4431
+ }
4432
+ /**
4433
+ * Overlay one record onto another, dropping any overlay key that could reach
4434
+ * `Object.prototype`. Every overlay merge goes through this so a `__proto__`
4435
+ * entry cannot enter the merged config from any depth — top-level
4436
+ * `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
4437
+ */
4438
+ function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
4439
+ const merged = { ...base };
4440
+ for (const [key, value] of Object.entries(overlay)) {
4441
+ if (isPrototypePollutionKey(key)) continue;
4442
+ merged[key] = value;
4443
+ }
4444
+ return merged;
4445
+ }
4446
+ function mergeMcpJsonOverlays({ base, overlay }) {
4447
+ const merged = { ...base };
4448
+ for (const [key, overlayValue] of Object.entries(overlay)) {
4449
+ if (isPrototypePollutionKey(key)) continue;
4450
+ if (key === "mcpServers") {
4451
+ merged.mcpServers = mergeRecordsSkippingPollutionKeys({
4452
+ base: getRecordField({
4453
+ value: base.mcpServers,
4454
+ path: "mcpServers"
4455
+ }),
4456
+ overlay: getRecordField({
4457
+ value: overlayValue,
4458
+ path: "mcpServers"
4459
+ })
4460
+ });
4461
+ continue;
4462
+ }
4463
+ if (TOOL_SCOPED_MCP_KEYS.has(key)) {
4464
+ const baseBlock = getRecordField({
4465
+ value: base[key],
4466
+ path: key
4467
+ });
4468
+ const overlayBlock = getRecordField({
4469
+ value: overlayValue,
4470
+ path: key
4471
+ });
4472
+ const mergedBlock = mergeRecordsSkippingPollutionKeys({
4473
+ base: baseBlock,
4474
+ overlay: overlayBlock
4475
+ });
4476
+ const baseServers = getRecordField({
4477
+ value: baseBlock.mcpServers,
4478
+ path: `${key}.mcpServers`
4479
+ });
4480
+ const overlayServers = getRecordField({
4481
+ value: overlayBlock.mcpServers,
4482
+ path: `${key}.mcpServers`
4483
+ });
4484
+ if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
4485
+ base: baseServers,
4486
+ overlay: overlayServers
4487
+ });
4488
+ merged[key] = mergedBlock;
4489
+ continue;
4490
+ }
4491
+ merged[key] = overlayValue;
4492
+ }
4493
+ return merged;
4494
+ }
3997
4495
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3998
4496
  json;
3999
4497
  constructor(params) {
@@ -4030,28 +4528,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4030
4528
  error: null
4031
4529
  };
4032
4530
  }
4033
- static async fromFile({ outputRoot = process.cwd(), validate = true, logger }) {
4531
+ /**
4532
+ * Load and merge MCP source files across the configured input roots.
4533
+ *
4534
+ * `inputRoots` entries are the source trees themselves (e.g.
4535
+ * `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
4536
+ * `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
4537
+ * legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
4538
+ * source tree and the first hit is loaded. Roots that have no candidate
4539
+ * contribute nothing.
4540
+ *
4541
+ * Cross-root behavior: the parsed JSON objects are folded left-to-right
4542
+ * with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
4543
+ * server name (one level deep) and replace every other value atomically.
4544
+ * With one root, this delegates to `fromFile` so JSONC formatting and the
4545
+ * actual candidate path are preserved. With multiple roots, each source is
4546
+ * parsed and schema-validated before merging so failures name the originating
4547
+ * file. The merged object is necessarily synthetic, serialized JSON anchored
4548
+ * to the first root's recommended path.
4549
+ *
4550
+ * A multi-root configuration where only one root actually supplies a file is
4551
+ * treated as the single-root case: nothing is merged, so the original file
4552
+ * content is kept verbatim (preserving JSONC comments) and the instance is
4553
+ * anchored at the root that supplied it rather than at the primary root's
4554
+ * recommended path.
4555
+ *
4556
+ * When no root supplies any candidate, this falls back to reading the
4557
+ * primary root's recommended path so the underlying file-not-found error
4558
+ * matches the single-root behavior of `fromFile`.
4559
+ */
4560
+ static async fromRoots({ inputRoots, validate = true, logger }) {
4561
+ if (inputRoots.length === 1) {
4562
+ const [primary] = inputRoots;
4563
+ return this.fromFile({
4564
+ outputRoot: dirname(primary),
4565
+ relativeDirPath: basename(primary),
4566
+ validate,
4567
+ logger
4568
+ });
4569
+ }
4570
+ const paths = this.getSettablePaths();
4571
+ const rootSources = [];
4572
+ for (const root of inputRoots) {
4573
+ const parent = dirname(root);
4574
+ const treeName = basename(root);
4575
+ const found = await findFirstExistingCandidate({
4576
+ paths,
4577
+ outputRoot: parent,
4578
+ overrideDirPath: treeName
4579
+ });
4580
+ if (found === void 0) continue;
4581
+ const { filePath, candidate } = found;
4582
+ if (filePath.endsWith(".mcp.json")) {
4583
+ const recommendedPath = join(parent, treeName, paths.recommended.relativeFilePath);
4584
+ logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4585
+ }
4586
+ const fileContent = await readFileContent(filePath);
4587
+ let parsed;
4588
+ try {
4589
+ parsed = parseJsonc(fileContent);
4590
+ if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
4591
+ if (validate) {
4592
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
4593
+ if (!result.success) throw result.error;
4594
+ }
4595
+ } catch (error) {
4596
+ throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
4597
+ }
4598
+ rootSources.push({
4599
+ record: parsed,
4600
+ outputRoot: parent,
4601
+ relativeDirPath: candidate.relativeDirPath,
4602
+ relativeFilePath: candidate.relativeFilePath,
4603
+ fileContent
4604
+ });
4605
+ }
4606
+ if (rootSources.length === 0) {
4607
+ const primary = inputRoots[0];
4608
+ return this.fromFile({
4609
+ outputRoot: dirname(primary),
4610
+ relativeDirPath: basename(primary),
4611
+ validate,
4612
+ logger
4613
+ });
4614
+ }
4615
+ const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
4616
+ if (onlySource !== void 0) return new RulesyncMcp({
4617
+ outputRoot: onlySource.outputRoot,
4618
+ relativeDirPath: onlySource.relativeDirPath,
4619
+ relativeFilePath: onlySource.relativeFilePath,
4620
+ fileContent: onlySource.fileContent,
4621
+ validate
4622
+ });
4623
+ const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
4624
+ base: acc,
4625
+ overlay: next.record
4626
+ }), {});
4627
+ const primary = inputRoots[0];
4628
+ return new RulesyncMcp({
4629
+ outputRoot: dirname(primary),
4630
+ relativeDirPath: basename(primary),
4631
+ relativeFilePath: paths.recommended.relativeFilePath,
4632
+ fileContent: JSON.stringify(merged, null, 2),
4633
+ validate
4634
+ });
4635
+ }
4636
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
4034
4637
  const paths = this.getSettablePaths();
4638
+ const overrideDirPath = relativeDirPath;
4035
4639
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
4036
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4640
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4641
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
4037
4642
  if (!await fileExists(filePath)) continue;
4038
4643
  if (candidate.relativeFilePath === ".mcp.json") {
4039
- const recommendedPath = join(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
4644
+ const recommendedPath = join(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
4040
4645
  logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4041
4646
  }
4042
4647
  const fileContent = await readFileContent(filePath);
4043
4648
  return new RulesyncMcp({
4044
4649
  outputRoot,
4045
- relativeDirPath: candidate.relativeDirPath,
4650
+ relativeDirPath: candidateDirPath,
4046
4651
  relativeFilePath: candidate.relativeFilePath,
4047
4652
  fileContent,
4048
4653
  validate
4049
4654
  });
4050
4655
  }
4051
- const fileContent = await readFileContent(join(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath));
4656
+ const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
4657
+ const fileContent = await readFileContent(join(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
4052
4658
  return new RulesyncMcp({
4053
4659
  outputRoot,
4054
- relativeDirPath: paths.recommended.relativeDirPath,
4660
+ relativeDirPath: fallbackDirPath,
4055
4661
  relativeFilePath: paths.recommended.relativeFilePath,
4056
4662
  fileContent,
4057
4663
  validate
@@ -5246,15 +5852,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5246
5852
  error: null
5247
5853
  };
5248
5854
  }
5249
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5855
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5250
5856
  const paths = RulesyncPermissions.getSettablePaths();
5857
+ const overrideDirPath = relativeDirPath;
5251
5858
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5252
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
5859
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
5860
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
5253
5861
  if (!await fileExists(filePath)) continue;
5254
5862
  const fileContent = await readFileContent(filePath);
5255
5863
  return new RulesyncPermissions({
5256
5864
  outputRoot,
5257
- relativeDirPath: candidate.relativeDirPath,
5865
+ relativeDirPath: candidateDirPath,
5258
5866
  relativeFilePath: candidate.relativeFilePath,
5259
5867
  fileContent,
5260
5868
  validate
@@ -5418,8 +6026,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5418
6026
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5419
6027
  };
5420
6028
  }
5421
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5422
- const filePath = join(outputRoot, this.getSettablePaths().recommended.relativeDirPath, relativeFilePath);
6029
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
6030
+ const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
6031
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
5423
6032
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5424
6033
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5425
6034
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5432,7 +6041,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5432
6041
  };
5433
6042
  return new RulesyncRule({
5434
6043
  outputRoot,
5435
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
6044
+ relativeDirPath: dirPath,
5436
6045
  relativeFilePath,
5437
6046
  frontmatter: validatedFrontmatter,
5438
6047
  body: content.trim(),
@@ -5444,8 +6053,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5444
6053
  }
5445
6054
  };
5446
6055
  //#endregion
6056
+ //#region src/utils/concurrency.ts
6057
+ /**
6058
+ * Map over items with a bounded number of operations in flight.
6059
+ *
6060
+ * `Promise.all(items.map(...))` starts every operation at once, which is fine
6061
+ * for a handful of paths and not fine for a directory tree of unknown size: a
6062
+ * few thousand concurrent `realpath` calls queue on the libuv thread pool and
6063
+ * hold their closures alive while they wait. Results keep the input order.
6064
+ *
6065
+ * `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
6066
+ * wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
6067
+ * around it, so every item's promise chain is allocated up front. This walks a
6068
+ * shared cursor with `limit` workers instead, so a list of unknown size costs
6069
+ * `limit` pending operations rather than one per item.
6070
+ */
6071
+ async function mapWithConcurrency({ items, limit, mapper }) {
6072
+ const results = Array.from({ length: items.length });
6073
+ let nextIndex = 0;
6074
+ const runWorker = async () => {
6075
+ while (nextIndex < items.length) {
6076
+ const index = nextIndex;
6077
+ nextIndex += 1;
6078
+ const item = items[index];
6079
+ if (item === void 0) continue;
6080
+ results[index] = await mapper(item);
6081
+ }
6082
+ };
6083
+ const workerCount = Math.max(1, Math.min(limit, items.length));
6084
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
6085
+ return results;
6086
+ }
6087
+ //#endregion
5447
6088
  //#region src/types/ai-dir.ts
5448
- var AiDir = class {
6089
+ /**
6090
+ * Directories that hold credentials. Excluding these protects something, so
6091
+ * their exclusion is reported rather than silent.
6092
+ */
6093
+ const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
6094
+ ".ssh",
6095
+ ".aws",
6096
+ ".gnupg"
6097
+ ]);
6098
+ /**
6099
+ * Directories that are refused only when the path leaves the skill directory to
6100
+ * reach them. These are the per-application trees of a home directory, where
6101
+ * naming every credential file is a list always one release behind -- `gcloud`
6102
+ * alone writes `credentials.db` and `application_default_credentials.json`, and
6103
+ * `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
6104
+ * own still carries it: what is refused is a link that reaches the *user's*.
6105
+ *
6106
+ * A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
6107
+ * the global skills this feature exists to share, so refusing a link that
6108
+ * reaches one would refuse the ordinary case along with the bad one.
6109
+ */
6110
+ const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
6111
+ ".config",
6112
+ ".local",
6113
+ ".azure",
6114
+ ".m2",
6115
+ ".terraform.d",
6116
+ ".docker",
6117
+ ".kube",
6118
+ "keychains"
6119
+ ]);
6120
+ /**
6121
+ * Whether an escaping real path passes through a directory only reachable by
6122
+ * escaping.
6123
+ *
6124
+ * Only the segments *past* the skill directory count. A global skill lives at
6125
+ * `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
6126
+ * the whole real path would refuse a link that never left the skills tree it
6127
+ * was already in -- the shared-directory case, reported as a credential.
6128
+ */
6129
+ function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
6130
+ const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
6131
+ const dirSegments = splitPathSegments(realDirPath).map(normalize);
6132
+ const fileSegments = splitPathSegments(realFilePath).map(normalize);
6133
+ let shared = 0;
6134
+ while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
6135
+ return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
6136
+ }
6137
+ /**
6138
+ * Directories that are never part of a skill for the ordinary reasons: a
6139
+ * nested repository, or a build/cache tree. Leaving these out is what a user
6140
+ * expects, so it happens quietly.
6141
+ */
6142
+ const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
6143
+ ".git",
6144
+ ".hg",
6145
+ ".svn",
6146
+ ".cache",
6147
+ ".venv",
6148
+ ".tox",
6149
+ ".mypy_cache",
6150
+ ".pytest_cache",
6151
+ ".ruff_cache",
6152
+ ".gradle",
6153
+ ".next",
6154
+ ".nuxt",
6155
+ ".turbo",
6156
+ ".parcel-cache",
6157
+ ".nyc_output",
6158
+ ".terraform"
6159
+ ]);
6160
+ /** Files that hold credentials. Compared lower-cased. */
6161
+ const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
6162
+ ".npmrc",
6163
+ ".netrc",
6164
+ ".git-credentials",
6165
+ ".pgpass",
6166
+ ".pypirc",
6167
+ ".htpasswd",
6168
+ ".dockercfg",
6169
+ ".envrc"
6170
+ ]);
6171
+ /** Files that are local noise. Compared lower-cased. */
6172
+ const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
6173
+ /** Credential files whose parent directory is otherwise ordinary content. */
6174
+ const NEVER_CARRIED_PATH_SUFFIXES = [
6175
+ ".docker/config.json",
6176
+ ".kube/config",
6177
+ ".config/gh/hosts.yml",
6178
+ ".config/gcloud/credentials.db",
6179
+ ".gem/credentials",
6180
+ "gcloud/application_default_credentials.json",
6181
+ ".codex/auth.json",
6182
+ ".gemini/oauth_creds.json"
6183
+ ];
6184
+ /** Whether a path ends in one of the credential files named above. */
6185
+ function endsWithNeverCarriedSuffix(filePath) {
6186
+ const posixPath = toPosixPath(filePath).toLowerCase();
6187
+ return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
6188
+ }
6189
+ /**
6190
+ * `.env.<suffix>` spellings that are templates rather than real values.
6191
+ * Everything else matching `.env*` is treated as holding secrets, because
6192
+ * `.env.production` is no less sensitive than `.env` itself.
6193
+ */
6194
+ const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
6195
+ "example",
6196
+ "sample",
6197
+ "template",
6198
+ "dist",
6199
+ "defaults"
6200
+ ]);
6201
+ /**
6202
+ * Kernel pseudo-filesystems, matched against the resolved real path. A link
6203
+ * into one of these does not reach a file at all: `/proc/self/environ` reads
6204
+ * back the entire environment of the running process, API keys included, and
6205
+ * `stat` reports it as an ordinary file. Nothing a skill carries lives here.
6206
+ */
6207
+ const NEVER_CARRIED_REAL_PATH_ROOTS = [
6208
+ "/proc",
6209
+ "/sys",
6210
+ "/dev"
6211
+ ];
6212
+ /**
6213
+ * Whether a directory of this name is pruned during the walk, so it is never
6214
+ * descended into at all. Derived from the directory names above so the pruning
6215
+ * and the path check below cannot drift apart.
6216
+ */
6217
+ function isNeverCarriedDirName(dirName) {
6218
+ const normalized = normalizePathSegment(dirName.toLowerCase());
6219
+ return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
6220
+ }
6221
+ /**
6222
+ * Windows drops trailing dots and spaces from a name, so a file called `.env `
6223
+ * is written as `.env` once it lands in a tool directory there. Normalizing
6224
+ * before every comparison means the name is judged as what it becomes.
6225
+ */
6226
+ function normalizePathSegment(segment) {
6227
+ const normalized = segment.replace(/[\s.]+$/, "");
6228
+ return normalized === "" ? segment : normalized;
6229
+ }
6230
+ /**
6231
+ * Why a path reaches something that is never skill content, or `undefined`
6232
+ * when it does not.
6233
+ *
6234
+ * Carrying hidden entries means a secret sitting in a skill directory would be
6235
+ * copied into every enabled tool root, multiplying the places it can be
6236
+ * committed from, and a `.venv` would be copied file by file into each of them.
6237
+ * None of these names is ever skill content, so excluding them costs nothing.
6238
+ *
6239
+ * The check is applied to the resolved real path as well as the literal one:
6240
+ * the names are what makes an entry dangerous, and a symbolic link named
6241
+ * `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
6242
+ * `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
6243
+ * and `.ssh` to the same file.
6244
+ */
6245
+ function classifyNeverCarried(relativePath) {
6246
+ const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
6247
+ const fileName = segments.at(-1) ?? "";
6248
+ const posixPath = segments.join("/");
6249
+ if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
6250
+ if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
6251
+ if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
6252
+ if (isCredentialFileName(fileName)) return "credential";
6253
+ if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
6254
+ if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
6255
+ }
6256
+ /**
6257
+ * Whether a single path segment names a credential file, whether it is the file
6258
+ * itself or a directory somebody gave that name to.
6259
+ */
6260
+ function isCredentialFileName(segment) {
6261
+ const name = normalizePathSegment(segment.toLowerCase());
6262
+ if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
6263
+ for (const base of [".env", ".envrc"]) {
6264
+ if (name === base) return true;
6265
+ if (name.startsWith(`${base}.`)) {
6266
+ const lastPiece = name.split(".").at(-1) ?? "";
6267
+ return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
6268
+ }
6269
+ }
6270
+ return false;
6271
+ }
6272
+ /** Whether a resolved real path points into a kernel pseudo-filesystem. */
6273
+ function isSystemPseudoPath(absolutePath) {
6274
+ const posixPath = toPosixPath(absolutePath);
6275
+ return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
6276
+ }
6277
+ /** How many links a chain may be followed before it is treated as a loop. */
6278
+ const MAX_LINK_CHAIN_HOPS = 40;
6279
+ /**
6280
+ * Whether reaching a path goes through a kernel pseudo-filesystem, even when it
6281
+ * does not end in one.
6282
+ *
6283
+ * Asking `realpath` alone is not enough, and the entries it is not enough for
6284
+ * are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
6285
+ * links, so resolving them lands *outside* `/proc`, on whatever file the
6286
+ * process happens to hold open — a private key another program is reading right
6287
+ * now would come back as an ordinary path and be carried. Following the chain a
6288
+ * hop at a time, and checking each hop, is what sees the `/proc` in the middle.
6289
+ */
6290
+ async function resolvesThroughSystemPseudoPath(filePath) {
6291
+ let currentPath = resolve(filePath);
6292
+ let hops = 0;
6293
+ for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
6294
+ if (isSystemPseudoPath(currentPath)) return {
6295
+ throughPseudoPath: true,
6296
+ hops
6297
+ };
6298
+ try {
6299
+ if (isSystemPseudoPath(await realpath(dirname(currentPath)))) return {
6300
+ throughPseudoPath: true,
6301
+ hops
6302
+ };
6303
+ } catch {}
6304
+ let linkStats;
6305
+ try {
6306
+ linkStats = await lstat(currentPath);
6307
+ } catch {
6308
+ return {
6309
+ throughPseudoPath: false,
6310
+ hops
6311
+ };
6312
+ }
6313
+ if (!linkStats.isSymbolicLink()) break;
6314
+ let target;
6315
+ try {
6316
+ target = await readlink(currentPath);
6317
+ } catch {
6318
+ return {
6319
+ throughPseudoPath: false,
6320
+ hops
6321
+ };
6322
+ }
6323
+ currentPath = isAbsolute(target) ? target : resolve(dirname(currentPath), target);
6324
+ }
6325
+ return {
6326
+ throughPseudoPath: false,
6327
+ hops
6328
+ };
6329
+ }
6330
+ const MAX_CARRIED_FILES = 1e4;
6331
+ const MAX_CARRIED_DIRECTORIES = 1e4;
6332
+ /**
6333
+ * The bounds above limit what is *carried*; this one limits what is *looked at*.
6334
+ * A directory holding nothing but a few hundred thousand links to itself carries
6335
+ * no files and occupies no depth, and would still cost a `stat` apiece.
6336
+ */
6337
+ const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
6338
+ const MAX_CARRIED_BYTES = 104857600;
6339
+ /** How many `realpath` calls the carried-file filter keeps in flight. */
6340
+ const CARRIED_REALPATH_CONCURRENCY = 32;
6341
+ /** Sort directory entries by name so a walk of the same tree is reproducible. */
6342
+ function compareByName(left, right) {
6343
+ if (left.name === right.name) return 0;
6344
+ return left.name < right.name ? -1 : 1;
6345
+ }
6346
+ /**
6347
+ * Order the routes that cross the same number of symbolic links: the one with
6348
+ * the fewest hidden segments first, so a named alias represents a shared tree
6349
+ * rather than a hidden one that a hidden-entry rule then refuses, taking the
6350
+ * named route's content with it.
6351
+ */
6352
+ function comparePendingCarriedDirs(left, right) {
6353
+ if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
6354
+ if (left.depth !== right.depth) return left.depth - right.depth;
6355
+ if (left.dirPath === right.dirPath) return 0;
6356
+ return left.dirPath < right.dirPath ? -1 : 1;
6357
+ }
6358
+ /**
6359
+ * Collect the files under a carried directory, following symbolic links but
6360
+ * visiting each real directory exactly once, by its cheapest route.
6361
+ *
6362
+ * A glob walk cannot do this safely. Two links in one directory that both point
6363
+ * back at an ancestor double the paths walked per level, and the walker follows
6364
+ * them until the kernel's ELOOP limit (~40), so the path array alone exhausts
6365
+ * the heap long before anything reads a file — a depth bound only lowers the
6366
+ * exponent, while the base is whatever number of links the tree's author chose.
6367
+ * Remembering the real directories already visited removes the multiplication
6368
+ * itself: a cycle, and an alias for a directory already walked, both stop at the
6369
+ * entry that closes them.
6370
+ *
6371
+ * Which route represents a directory then matters, because the others are
6372
+ * dropped. The walk proceeds in rounds by the number of symbolic links crossed:
6373
+ * everything reachable without crossing one, then everything one link away, and
6374
+ * so on. A real location therefore always wins over an alias for it — at any
6375
+ * nesting depth, not just among siblings, which a depth-first walk could not
6376
+ * promise — and among aliases the named one wins over a hidden one.
6377
+ *
6378
+ * A broken link is skipped: it resolves to nothing to read.
6379
+ */
6380
+ async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
6381
+ const filePaths = [];
6382
+ const visitedRealDirPaths = /* @__PURE__ */ new Set();
6383
+ const truncations = /* @__PURE__ */ new Set();
6384
+ const unreadablePaths = /* @__PURE__ */ new Set();
6385
+ const pseudoPaths = /* @__PURE__ */ new Set();
6386
+ const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
6387
+ let deferredLinkedDirs = [];
6388
+ /** Whether the walk has hit a bound that ends it rather than one branch of it. */
6389
+ let examinedEntries = 0;
6390
+ const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
6391
+ const addFile = (filePath) => {
6392
+ if (filePaths.length >= 1e4) {
6393
+ truncations.add("count");
6394
+ return;
6395
+ }
6396
+ filePaths.push(filePath);
6397
+ };
6398
+ /** Carry what a symbolic link names, or hold its directory for the next round. */
6399
+ const routeLinkedEntry = async (child, entryName) => {
6400
+ let targetStats;
6401
+ try {
6402
+ targetStats = await stat(child.dirPath);
6403
+ } catch {
6404
+ return;
6405
+ }
6406
+ if (targetStats.isFile()) {
6407
+ addFile(child.dirPath);
6408
+ return;
6409
+ }
6410
+ if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
6411
+ const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
6412
+ examinedEntries += hops;
6413
+ if (throughPseudoPath) {
6414
+ pseudoPaths.add(child.dirPath);
6415
+ return;
6416
+ }
6417
+ deferredLinkedDirs.push(child);
6418
+ };
6419
+ /** Walk one directory and everything below it that no symbolic link leads to. */
6420
+ const walkWithoutCrossingLinks = async (pending) => {
6421
+ if (isFull()) return;
6422
+ let realCurrentPath;
6423
+ try {
6424
+ realCurrentPath = await realpath(pending.dirPath);
6425
+ } catch {
6426
+ unreadablePaths.add(pending.dirPath);
6427
+ return;
6428
+ }
6429
+ if (visitedRealDirPaths.has(realCurrentPath)) return;
6430
+ if (isSystemPseudoPath(realCurrentPath)) {
6431
+ pseudoPaths.add(pending.dirPath);
6432
+ return;
6433
+ }
6434
+ if (pending.depth > 12) {
6435
+ depthStoppedRealDirPaths.add(realCurrentPath);
6436
+ return;
6437
+ }
6438
+ if (visitedRealDirPaths.size >= 1e4) {
6439
+ truncations.add("directories");
6440
+ return;
6441
+ }
6442
+ visitedRealDirPaths.add(realCurrentPath);
6443
+ let entries;
6444
+ try {
6445
+ entries = await readdir(pending.dirPath, { withFileTypes: true });
6446
+ } catch {
6447
+ unreadablePaths.add(pending.dirPath);
6448
+ return;
6449
+ }
6450
+ const realSubDirs = [];
6451
+ for (const entry of entries.toSorted(compareByName)) {
6452
+ if (isFull()) return;
6453
+ examinedEntries += 1;
6454
+ if (examinedEntries > 2e5) {
6455
+ truncations.add("entries");
6456
+ return;
6457
+ }
6458
+ if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
6459
+ const entryPath = join(pending.dirPath, entry.name);
6460
+ const child = {
6461
+ dirPath: entryPath,
6462
+ depth: pending.depth + 1,
6463
+ hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
6464
+ };
6465
+ if (entry.isFile()) {
6466
+ addFile(entryPath);
6467
+ continue;
6468
+ }
6469
+ if (entry.isDirectory()) {
6470
+ if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
6471
+ continue;
6472
+ }
6473
+ if (!entry.isSymbolicLink()) continue;
6474
+ await routeLinkedEntry(child, entry.name);
6475
+ }
6476
+ for (const realSubDir of realSubDirs) {
6477
+ if (isFull()) return;
6478
+ await walkWithoutCrossingLinks(realSubDir);
6479
+ }
6480
+ };
6481
+ let round = [{
6482
+ dirPath,
6483
+ depth: 0,
6484
+ hiddenSegments: 0
6485
+ }];
6486
+ while (round.length > 0 && !isFull()) {
6487
+ deferredLinkedDirs = [];
6488
+ for (const pending of round.toSorted(comparePendingCarriedDirs)) {
6489
+ if (isFull()) break;
6490
+ await walkWithoutCrossingLinks(pending);
6491
+ }
6492
+ round = deferredLinkedDirs;
6493
+ }
6494
+ for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
6495
+ truncations.add("depth");
6496
+ break;
6497
+ }
6498
+ return {
6499
+ filePaths: filePaths.toSorted(),
6500
+ truncations,
6501
+ unreadablePaths: [...unreadablePaths],
6502
+ pseudoPaths: [...pseudoPaths]
6503
+ };
6504
+ }
6505
+ /** Render a set of refused or noteworthy paths for one warning line. */
6506
+ function formatReportedPaths(paths) {
6507
+ const sorted = [...paths].toSorted();
6508
+ const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
6509
+ const remaining = sorted.length - 10;
6510
+ return {
6511
+ count: sorted.length,
6512
+ list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
6513
+ };
6514
+ }
6515
+ /** "entry" or "entries", so the warnings below read as sentences. */
6516
+ function entryWord(count) {
6517
+ return count === 1 ? "entry" : "entries";
6518
+ }
6519
+ /** "resolves" or "resolve", to agree with the entry count it follows. */
6520
+ function resolveWord(count) {
6521
+ return count === 1 ? "resolves" : "resolve";
6522
+ }
6523
+ var AiDir = class AiDir {
5449
6524
  /**
5450
6525
  * @example "."
5451
6526
  */
@@ -5495,8 +6570,7 @@ var AiDir = class {
5495
6570
  const fullPath = path.join(this.outputRoot, this.relativeDirPath, this.dirName);
5496
6571
  const resolvedFull = resolve(fullPath);
5497
6572
  const resolvedBase = resolve(this.outputRoot);
5498
- const rel = relative(resolvedBase, resolvedFull);
5499
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
6573
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
5500
6574
  return fullPath;
5501
6575
  }
5502
6576
  getMainFile() {
@@ -5522,9 +6596,227 @@ var AiDir = class {
5522
6596
  };
5523
6597
  }
5524
6598
  /**
6599
+ * A nested repository inside a carried directory is the one exclusion worth
6600
+ * reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
6601
+ * points at is simply not reproduced on generate. Only the top level is
6602
+ * checked, which is where a submodule or a stray `git init` puts it, so the
6603
+ * check costs one stat per directory rather than a second walk. `fileExists`
6604
+ * is a bare `stat`, so it answers for a submodule pointer file and for a real
6605
+ * `.git` directory alike.
6606
+ */
6607
+ static async warnOnNestedGitDirectory(dirPath) {
6608
+ const gitEntryPath = join(dirPath, ".git");
6609
+ 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.`);
6610
+ }
6611
+ /** Whether any segment of a relative path is dot-prefixed. */
6612
+ static hasHiddenSegment(relativePath) {
6613
+ return splitPathSegments(relativePath).some(isHiddenPathSegment);
6614
+ }
6615
+ /**
6616
+ * Whether the entry a path ends at is itself hidden, its ancestors aside.
6617
+ * A shared skill tree usually lives under a dot-directory, so an ancestor
6618
+ * says nothing about the file; its own name is what was chosen for it.
6619
+ */
6620
+ static hasHiddenName(relativePath) {
6621
+ return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
6622
+ }
6623
+ /**
6624
+ * Drop the entries a skill directory must not carry.
6625
+ *
6626
+ * Three rules. `classifyNeverCarried` comes first, evaluated against the
6627
+ * resolved real path as well as the literal one: names that are never skill
6628
+ * content stay out however they are reached, so renaming a symbolic link
6629
+ * does not turn `~/.aws` into content a skill carries. Next, a real path
6630
+ * inside a kernel pseudo-filesystem is refused outright — that is process
6631
+ * state, not a file.
6632
+ *
6633
+ * The third concerns hidden entries a symbolic link reaches outside the
6634
+ * directory. Following symlinks out of a source tree is deliberate and
6635
+ * documented — it is how a shared skill is referenced from several projects
6636
+ * without being duplicated (issue #1707), and the trust boundary is the tree
6637
+ * you point Rulesync at. Carrying hidden entries changes what that costs,
6638
+ * though: one ordinary-looking link to a home directory would pull in every
6639
+ * dotfile under it, and those are the entries with credential value. What
6640
+ * decides is the name in the skill directory, not what the link resolves
6641
+ * through: a named file keeps its documented behavior even when the target
6642
+ * sits under a dot-directory such as `~/.dotfiles`, because somebody chose
6643
+ * that name. What the link resolves *to* still counts at the end of the path,
6644
+ * though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
6645
+ * file nobody named for a skill — so a hidden final segment is refused on
6646
+ * either side. Reaching outside is reported either way, since content from
6647
+ * outside the tree is about to be copied into every enabled tool root.
6648
+ *
6649
+ * A path that cannot be resolved is kept: `realpath` fails on a broken link
6650
+ * or a race, and neither is a reason to silently drop a file.
6651
+ */
6652
+ /** Report what a carried directory left out, once both walks have had their say. */
6653
+ static warnOnRefusedCarriedFiles(dirPath, carried) {
6654
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6655
+ if (carried.refusedCredentials.size > 0) {
6656
+ const { count, list } = formatReportedPaths(carried.refusedCredentials);
6657
+ 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.`);
6658
+ }
6659
+ if (carried.refusedPseudoPaths.size > 0) {
6660
+ const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
6661
+ 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.`);
6662
+ }
6663
+ if (carried.refusedNoiseAliases.size > 0) {
6664
+ const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
6665
+ 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.`);
6666
+ }
6667
+ if (carried.refusedEscapedHidden.size > 0) {
6668
+ const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
6669
+ 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.`);
6670
+ }
6671
+ if (carried.carriedFromOutside.size > 0) {
6672
+ const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
6673
+ const realFilePath = carried.realFilePathByPath.get(filePath);
6674
+ return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
6675
+ }));
6676
+ warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
6677
+ }
6678
+ }
6679
+ static async filterCarriedFiles(dirPath, filePaths) {
6680
+ let realDirPath;
6681
+ try {
6682
+ realDirPath = await realpath(dirPath);
6683
+ } catch {
6684
+ realDirPath = resolve(dirPath);
6685
+ }
6686
+ const refusedCredentials = /* @__PURE__ */ new Set();
6687
+ const refusedPseudoPaths = /* @__PURE__ */ new Set();
6688
+ const refusedEscapedHidden = /* @__PURE__ */ new Set();
6689
+ const refusedNoiseAliases = /* @__PURE__ */ new Set();
6690
+ const carriedFromOutside = /* @__PURE__ */ new Set();
6691
+ const realFilePathByPath = /* @__PURE__ */ new Map();
6692
+ const verdicts = await mapWithConcurrency({
6693
+ items: filePaths,
6694
+ limit: CARRIED_REALPATH_CONCURRENCY,
6695
+ mapper: async (filePath) => {
6696
+ const literalPath = relative(dirPath, filePath);
6697
+ const literalReason = classifyNeverCarried(literalPath);
6698
+ if (literalReason !== void 0) {
6699
+ if (literalReason === "credential") refusedCredentials.add(filePath);
6700
+ return false;
6701
+ }
6702
+ let realFilePath;
6703
+ try {
6704
+ realFilePath = await realpath(filePath);
6705
+ } catch {
6706
+ return true;
6707
+ }
6708
+ realFilePathByPath.set(filePath, realFilePath);
6709
+ if (isSystemPseudoPath(realFilePath)) {
6710
+ refusedPseudoPaths.add(filePath);
6711
+ return false;
6712
+ }
6713
+ const realPath = relative(realDirPath, realFilePath);
6714
+ const realReason = classifyNeverCarried(realPath);
6715
+ if (realReason !== void 0) {
6716
+ if (realReason === "credential") refusedCredentials.add(filePath);
6717
+ else refusedNoiseAliases.add(filePath);
6718
+ return false;
6719
+ }
6720
+ if (!pathEscapesRoot(realPath)) return true;
6721
+ if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
6722
+ refusedPseudoPaths.add(filePath);
6723
+ return false;
6724
+ }
6725
+ if (endsWithNeverCarriedSuffix(realFilePath)) {
6726
+ refusedCredentials.add(filePath);
6727
+ return false;
6728
+ }
6729
+ if (escapesIntoCredentialDir({
6730
+ realDirPath,
6731
+ realFilePath
6732
+ })) {
6733
+ refusedCredentials.add(filePath);
6734
+ return false;
6735
+ }
6736
+ if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
6737
+ refusedEscapedHidden.add(filePath);
6738
+ return false;
6739
+ }
6740
+ carriedFromOutside.add(filePath);
6741
+ return true;
6742
+ }
6743
+ });
6744
+ return {
6745
+ filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
6746
+ realFilePathByPath,
6747
+ refusedCredentials,
6748
+ refusedPseudoPaths,
6749
+ refusedEscapedHidden,
6750
+ refusedNoiseAliases,
6751
+ carriedFromOutside
6752
+ };
6753
+ }
6754
+ /**
6755
+ * Report what the walk had to leave behind, so a skill that silently lost
6756
+ * files says so rather than generating a directory that is quietly short.
6757
+ */
6758
+ static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
6759
+ 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.`);
6760
+ 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.`);
6761
+ 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.`);
6762
+ 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.`);
6763
+ if (unreadablePaths.length > 0) {
6764
+ const { count, list } = formatReportedPaths(unreadablePaths);
6765
+ 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.`);
6766
+ }
6767
+ }
6768
+ /**
6769
+ * Walk the directory once more with the hidden routes pruned, and take the
6770
+ * files the first walk had to leave behind because the route that reached
6771
+ * them ran through a hidden directory. This can only add files, and only ones
6772
+ * a fully named route reaches.
6773
+ */
6774
+ static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
6775
+ const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
6776
+ const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
6777
+ for (const truncation of named.truncations) walk.truncations.add(truncation);
6778
+ for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
6779
+ for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6780
+ for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
6781
+ for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
6782
+ for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
6783
+ for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
6784
+ for (const filePath of recovered.filePaths) {
6785
+ const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
6786
+ if (carriedRealPaths.has(realFilePath)) continue;
6787
+ if (carriedPaths.length >= 1e4) {
6788
+ walk.truncations.add("count");
6789
+ break;
6790
+ }
6791
+ carriedRealPaths.add(realFilePath);
6792
+ carriedPaths.push(filePath);
6793
+ carried.realFilePathByPath.set(filePath, realFilePath);
6794
+ if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
6795
+ }
6796
+ for (const filePath of carried.refusedEscapedHidden) {
6797
+ const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
6798
+ if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
6799
+ }
6800
+ }
6801
+ /**
5525
6802
  * Recursively collects all files from a directory, excluding the specified main file.
5526
6803
  * This is a common utility for loading additional files alongside the main file.
5527
6804
  *
6805
+ * Hidden entries are included. The directories this walks are skill trees,
6806
+ * whose specification says a skill directory "may contain any files and
6807
+ * directories beyond the required `SKILL.md`" — a `.env.example` beside the
6808
+ * scripts that read it is content, not noise, and dropping it silently on
6809
+ * both import and generate loses part of the skill. What is left out is the
6810
+ * set of entries that are never skill content — a nested repository's `.git`,
6811
+ * the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
6812
+ * as decided by `classifyNeverCarried`. Whole directories from that set are
6813
+ * pruned during the walk too, so it never descends into them at all.
6814
+ *
6815
+ * The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
6816
+ * tree may contain symbolic links that somebody else chose.
6817
+ *
6818
+ * @see https://agentskills.io/specification
6819
+ *
5528
6820
  * @param outputRoot - The base directory path
5529
6821
  * @param relativeDirPath - The relative path to the directory containing the skill
5530
6822
  * @param dirName - The name of the directory
@@ -5533,14 +6825,57 @@ var AiDir = class {
5533
6825
  */
5534
6826
  static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
5535
6827
  const dirPath = join(outputRoot, relativeDirPath, dirName);
5536
- const filteredPaths = (await findFilesByGlobs(join(dirPath, "**", "*"), { type: "file" })).filter((filePath) => basename(filePath) !== excludeFileName);
5537
- return await Promise.all(filteredPaths.map(async (filePath) => {
5538
- const fileBuffer = await readFileBuffer(filePath);
5539
- return {
5540
- relativeFilePathToDirPath: relative(dirPath, filePath),
5541
- fileBuffer
5542
- };
5543
- }));
6828
+ const walk = await walkCarriedFiles(dirPath);
6829
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6830
+ await AiDir.warnOnNestedGitDirectory(dirPath);
6831
+ const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
6832
+ const carriedPaths = [...carried.filePaths];
6833
+ const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
6834
+ if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
6835
+ dirPath,
6836
+ carried,
6837
+ carriedPaths,
6838
+ carriedRealPaths,
6839
+ walk
6840
+ });
6841
+ for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6842
+ AiDir.warnOnCarriedWalkLimits({
6843
+ reportedDirPath,
6844
+ truncations: walk.truncations,
6845
+ unreadablePaths: walk.unreadablePaths
6846
+ });
6847
+ AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
6848
+ const filteredPaths = carriedPaths.toSorted().filter((filePath) => basename(filePath) !== excludeFileName);
6849
+ const files = [];
6850
+ let carriedBytes = 0;
6851
+ for (const [index, filePath] of filteredPaths.entries()) {
6852
+ const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
6853
+ let fileHandle;
6854
+ try {
6855
+ fileHandle = await open(classifiedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
6856
+ } catch (error) {
6857
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6858
+ continue;
6859
+ }
6860
+ try {
6861
+ const fileSize = (await fileHandle.stat()).size;
6862
+ if (carriedBytes + fileSize > 104857600) {
6863
+ 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.`);
6864
+ break;
6865
+ }
6866
+ const fileBuffer = await fileHandle.readFile();
6867
+ carriedBytes += fileBuffer.byteLength;
6868
+ files.push({
6869
+ relativeFilePathToDirPath: relative(dirPath, filePath),
6870
+ fileBuffer
6871
+ });
6872
+ } catch (error) {
6873
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6874
+ } finally {
6875
+ await fileHandle.close();
6876
+ }
6877
+ }
6878
+ return files;
5544
6879
  }
5545
6880
  };
5546
6881
  const RulesyncSkillFrontmatterSchema = z.looseObject({
@@ -5680,7 +7015,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5680
7015
  "disable-model-invocation": z.optional(z.boolean()),
5681
7016
  "user-invocable": z.optional(z.boolean()),
5682
7017
  enabled: z.optional(z.boolean()),
5683
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
7018
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
7019
+ license: z.optional(z.unknown()),
7020
+ compatibility: z.optional(z.unknown()),
7021
+ metadata: z.optional(z.unknown()),
7022
+ version: z.optional(z.unknown())
5684
7023
  })),
5685
7024
  grokcli: z.optional(z.looseObject({
5686
7025
  "disable-model-invocation": z.optional(z.boolean()),
@@ -5763,7 +7102,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
5763
7102
  const skillDirPath = join(outputRoot, relativeDirPath, dirName);
5764
7103
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
5765
7104
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
5766
- const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
7105
+ const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
5767
7106
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5768
7107
  const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
5769
7108
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
@@ -5856,8 +7195,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5856
7195
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5857
7196
  };
5858
7197
  }
5859
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5860
- const filePath = join(outputRoot, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath);
7198
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
7199
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
7200
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
5861
7201
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5862
7202
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5863
7203
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5865,7 +7205,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5865
7205
  const filename = basename(relativeFilePath);
5866
7206
  return new RulesyncSubagent({
5867
7207
  outputRoot,
5868
- relativeDirPath: this.getSettablePaths().relativeDirPath,
7208
+ relativeDirPath: dirPath,
5869
7209
  relativeFilePath: filename,
5870
7210
  frontmatter: result.data,
5871
7211
  body: content.trim()
@@ -5875,16 +7215,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5875
7215
  //#endregion
5876
7216
  //#region src/features/skills/skills-utils.ts
5877
7217
  /**
5878
- * Returns the set of local skill directory names (excluding `.curated`).
7218
+ * Returns the set of local skill directory names (excluding `.curated`)
7219
+ * from a rulesync source tree (e.g. `/repo/.rulesync` or
7220
+ * `/repo/.rulesync.local`).
5879
7221
  */
5880
- async function getLocalSkillDirNames(outputRoot) {
5881
- const skillsDir = join(outputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
7222
+ async function getLocalSkillDirNames(sourceTree) {
7223
+ const skillsDir = join(sourceTree, SKILLS_FEATURE_SUBDIR);
5882
7224
  const names = /* @__PURE__ */ new Set();
5883
7225
  if (!await directoryExists(skillsDir)) return names;
5884
7226
  const dirPaths = await findFilesByGlobs(join(skillsDir, "*"), { type: "dir" });
5885
7227
  for (const dirPath of dirPaths) {
5886
7228
  const name = basename(dirPath);
5887
- if (name === basename(RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH)) continue;
7229
+ if (name === basename(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
5888
7230
  names.add(name);
5889
7231
  }
5890
7232
  return names;
@@ -6200,12 +7542,25 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6200
7542
  //#region src/types/feature-processor.ts
6201
7543
  var FeatureProcessor = class {
6202
7544
  outputRoot;
6203
- inputRoot;
7545
+ /**
7546
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
7547
+ * is a source tree itself — the directory that directly contains feature
7548
+ * subdirectories (`rules/`, `commands/`, …) and single-file features
7549
+ * (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
7550
+ * per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
7551
+ *
7552
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
7553
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
7554
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
7555
+ * reaches a processor — every internal consumer only ever sees the
7556
+ * plural form, with the source tree already resolved.
7557
+ */
7558
+ inputRoots;
6204
7559
  dryRun;
6205
7560
  logger;
6206
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
7561
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6207
7562
  this.outputRoot = outputRoot;
6208
- this.inputRoot = inputRoot;
7563
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
6209
7564
  this.dryRun = dryRun;
6210
7565
  this.logger = logger;
6211
7566
  }
@@ -6272,6 +7627,198 @@ var FeatureProcessor = class {
6272
7627
  return orphanFiles.length;
6273
7628
  }
6274
7629
  };
7630
+ /**
7631
+ * Messages already reported for a given logger.
7632
+ *
7633
+ * One `generate` run constructs a single-file processor per tool target and
7634
+ * per output root — more than twenty times for `--targets "*"` — and each one
7635
+ * re-resolves the same roots. Keying on the logger — created once per run, and
7636
+ * once per test — keeps the shadowing warning to a single line instead of
7637
+ * repeating it for every target. `--watch` reuses one logger across runs, so
7638
+ * {@link resetRootShadowingWarnings} clears the set at the start of each one.
7639
+ */
7640
+ const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
7641
+ /**
7642
+ * Forget which shadowing warnings have already been reported.
7643
+ *
7644
+ * `generate` calls this once per run. Without it, `--watch` reuses one logger
7645
+ * for the whole session, so the warning would be printed on the first
7646
+ * generation and never again — the opposite of why it is a warning, since
7647
+ * `--watch` is exactly when an overlay is most likely to be added or edited.
7648
+ */
7649
+ function resetRootShadowingWarnings({ logger }) {
7650
+ warnedRootShadowingByLogger.delete(logger);
7651
+ }
7652
+ /**
7653
+ * Return the last input root that contains any of the given `relativePaths`,
7654
+ * or `undefined` when none of the roots has any of them. Used by single-file
7655
+ * features (hooks, permissions, ignore) to implement the "later root wins
7656
+ * the whole file" merge policy without materializing the file contents.
7657
+ *
7658
+ * `relativePaths` accepts a small list so features that historically read
7659
+ * either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
7660
+ * plus `.rulesync/mcp.json`) can preserve that resolution order per root.
7661
+ * A root counts as "having" the file as long as at least one candidate path
7662
+ * is present.
7663
+ */
7664
+ async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
7665
+ let winner;
7666
+ const rootsWithFile = [];
7667
+ for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists(join(root, relativePath))) {
7668
+ winner = root;
7669
+ rootsWithFile.push(root);
7670
+ break;
7671
+ }
7672
+ if (rootsWithFile.length > 1 && winner !== void 0) {
7673
+ const shadowed = rootsWithFile.slice(0, -1);
7674
+ const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
7675
+ let warnedMessages = warnedRootShadowingByLogger.get(logger);
7676
+ if (warnedMessages === void 0) {
7677
+ warnedMessages = /* @__PURE__ */ new Set();
7678
+ warnedRootShadowingByLogger.set(logger, warnedMessages);
7679
+ }
7680
+ if (!warnedMessages.has(message)) {
7681
+ warnedMessages.add(message);
7682
+ logger.warn(message);
7683
+ }
7684
+ }
7685
+ return winner;
7686
+ }
7687
+ /**
7688
+ * Merge per-root result lists into a single ordered list, keeping the
7689
+ * later root's entry when two roots produced an item with the same
7690
+ * identity. Identity is intentionally provided by the caller so
7691
+ * per-feature nuances (case-insensitive filesystems, directory names for
7692
+ * skills, server names for MCP) live next to the feature that owns them.
7693
+ *
7694
+ * The returned list preserves the FIRST appearance order of each identity —
7695
+ * items in the earliest root keep their position, but their content is
7696
+ * replaced by the last root that provided the same identity. This matches
7697
+ * the "overlay" mental model: an overlay changes content, not order.
7698
+ */
7699
+ function mergeByIdentity({ perRoot, identity }) {
7700
+ const order = [];
7701
+ const winnerByKey = /* @__PURE__ */ new Map();
7702
+ for (const rootItems of perRoot) for (const item of rootItems) {
7703
+ const key = identity(item);
7704
+ if (!winnerByKey.has(key)) order.push(key);
7705
+ winnerByKey.set(key, item);
7706
+ }
7707
+ return order.map((key) => winnerByKey.get(key));
7708
+ }
7709
+ /**
7710
+ * The key two spellings share when a case-insensitive filesystem would give
7711
+ * them one file. `toLowerCase()` is locale-independent (unlike
7712
+ * `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
7713
+ * locale), and the NFC pass folds the composed and decomposed spellings of an
7714
+ * accented name — which macOS also resolves to a single directory —
7715
+ * onto each other.
7716
+ *
7717
+ * This is simple lowercasing rather than full Unicode case folding, so it is
7718
+ * deliberately narrower than what a filesystem considers one file: a Greek
7719
+ * final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
7720
+ * trailing dot is stripped all still produce distinct keys. Those pairs keep
7721
+ * the pre-existing behavior (both are imported, and the later one wins on the
7722
+ * filesystem); folding them here would instead drop names that a
7723
+ * case-sensitive filesystem keeps genuinely apart.
7724
+ */
7725
+ function caseFoldIdentity(identity) {
7726
+ return identity.normalize("NFC").toLowerCase();
7727
+ }
7728
+ /**
7729
+ * Group spellings by their case-folded identity, keeping every original
7730
+ * spelling. On a case-sensitive filesystem one identity can cover several
7731
+ * spellings at once, and the caller needs them all to describe a collision
7732
+ * accurately.
7733
+ */
7734
+ function groupSpellingsByCaseFoldedIdentity(spellings) {
7735
+ const grouped = /* @__PURE__ */ new Map();
7736
+ for (const spelling of spellings) {
7737
+ const identity = caseFoldIdentity(spelling);
7738
+ const existing = grouped.get(identity);
7739
+ if (existing === void 0) grouped.set(identity, [spelling]);
7740
+ else existing.push(spelling);
7741
+ }
7742
+ return grouped;
7743
+ }
7744
+ /**
7745
+ * Build the warning emitted when a `.curated/` entry and a local entry in the
7746
+ * same tree differ only in case.
7747
+ *
7748
+ * `.curated/` is expanded from a declarative source (an external Git repository
7749
+ * or npm package), so its names are untrusted input; both sides are stripped of
7750
+ * control characters before they reach the terminal.
7751
+ *
7752
+ * The winning local spelling is the LAST one, matching the precedence
7753
+ * {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
7754
+ * that folds onto the same identity is listed too, so the message never names a
7755
+ * spelling that loses.
7756
+ */
7757
+ function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
7758
+ const winner = localSpellings[localSpellings.length - 1] ?? "";
7759
+ const shadowed = localSpellings.slice(0, -1);
7760
+ const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
7761
+ 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;
7762
+ }
7763
+ /**
7764
+ * Merge artifacts whose filenames are case-insensitive identities, warning
7765
+ * when distinct spellings collapse to the same key. Exact-name overlays are
7766
+ * intentional and remain quiet; only case-only ambiguity is diagnosed.
7767
+ *
7768
+ * Precedence here is the opposite of {@link ClaimedIdentities}: this merges
7769
+ * overlay roots, where the LAST entry wins, while the tool-side loaders keep
7770
+ * the FIRST root to claim a name.
7771
+ */
7772
+ function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
7773
+ const spellingByKey = /* @__PURE__ */ new Map();
7774
+ const warnedKeys = /* @__PURE__ */ new Set();
7775
+ return mergeByIdentity({
7776
+ perRoot,
7777
+ identity: (item) => {
7778
+ const spelling = identity(item);
7779
+ const key = caseFoldIdentity(spelling);
7780
+ const previousSpelling = spellingByKey.get(key);
7781
+ if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
7782
+ logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
7783
+ warnedKeys.add(key);
7784
+ }
7785
+ if (previousSpelling === void 0) spellingByKey.set(key, spelling);
7786
+ return key;
7787
+ }
7788
+ });
7789
+ }
7790
+ /**
7791
+ * Tracks the import identities already claimed while scanning, folding case
7792
+ * through {@link caseFoldIdentity}.
7793
+ *
7794
+ * The tool-side loaders scan several roots in precedence order and keep the
7795
+ * first spelling of each identity. Comparing those identities exactly lets
7796
+ * `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
7797
+ * since macOS and Windows resolve the two written-back directories to a
7798
+ * single one, the shared Agent Skills copy lands last and overwrites the
7799
+ * tool-specific one — inverting the precedence the roots were ordered by.
7800
+ *
7801
+ * The FIRST claimer wins, which is the opposite of the overlay precedence in
7802
+ * {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
7803
+ * order, so the earliest one to name a skill is the one that should keep it.
7804
+ */
7805
+ var ClaimedIdentities = class {
7806
+ claimByKey = /* @__PURE__ */ new Map();
7807
+ /**
7808
+ * Claim `identity` on behalf of `source`. Returns `null` when nothing held
7809
+ * it yet, or the standing claim when something did.
7810
+ */
7811
+ claim({ identity, source }) {
7812
+ const key = caseFoldIdentity(identity);
7813
+ const claimed = this.claimByKey.get(key);
7814
+ if (claimed !== void 0) return claimed;
7815
+ this.claimByKey.set(key, {
7816
+ spelling: identity,
7817
+ source
7818
+ });
7819
+ return null;
7820
+ }
7821
+ };
6275
7822
  //#endregion
6276
7823
  //#region src/constants/amp-paths.ts
6277
7824
  const AMP_DIR = ".amp";
@@ -8594,10 +10141,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8594
10141
  toolTarget;
8595
10142
  global;
8596
10143
  getFactory;
8597
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
10144
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
8598
10145
  super({
8599
10146
  outputRoot,
8600
- inputRoot,
10147
+ inputRoots,
8601
10148
  dryRun,
8602
10149
  logger
8603
10150
  });
@@ -8634,11 +10181,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8634
10181
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8635
10182
  }
8636
10183
  /**
8637
- * Implementation of abstract method from Processor
8638
- * Load and parse rulesync check files from .rulesync/checks/ directory
10184
+ * Load check files from a single source-tree's `checks/` subtree.
10185
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
10186
+ * `/repo/.rulesync.local`).
8639
10187
  */
8640
- async loadRulesyncFiles() {
8641
- const checksDir = join(this.inputRoot, RulesyncCheck.getSettablePaths().relativeDirPath);
10188
+ async loadRulesyncFilesForRoot(sourceTree) {
10189
+ const treeParent = dirname(sourceTree);
10190
+ const treeName = basename(sourceTree);
10191
+ const treeChecksDirPath = join(treeName, CHECKS_FEATURE_SUBDIR);
10192
+ const checksDir = join(sourceTree, CHECKS_FEATURE_SUBDIR);
8642
10193
  if (!await directoryExists(checksDir)) {
8643
10194
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8644
10195
  return [];
@@ -8654,7 +10205,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8654
10205
  const filepath = join(checksDir, mdFile);
8655
10206
  try {
8656
10207
  const rulesyncCheck = await RulesyncCheck.fromFile({
8657
- outputRoot: this.inputRoot,
10208
+ outputRoot: treeParent,
10209
+ relativeDirPath: treeChecksDirPath,
8658
10210
  relativeFilePath: mdFile,
8659
10211
  validate: true
8660
10212
  });
@@ -8665,6 +10217,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8665
10217
  continue;
8666
10218
  }
8667
10219
  }
10220
+ return rulesyncChecks;
10221
+ }
10222
+ /**
10223
+ * Implementation of abstract method from Processor
10224
+ * Load and parse rulesync check files from every configured input root's
10225
+ * `.rulesync/checks/` directory, merging by relative file path so a check
10226
+ * from a later root replaces the earlier root's copy.
10227
+ */
10228
+ async loadRulesyncFiles() {
10229
+ const rulesyncChecks = mergeByCaseInsensitiveIdentity({
10230
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
10231
+ identity: (check) => check.getRelativeFilePath(),
10232
+ artifactName: "check",
10233
+ logger: this.logger
10234
+ });
8668
10235
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8669
10236
  return rulesyncChecks;
8670
10237
  }
@@ -10143,18 +11710,23 @@ function commandSlug(relativeFilePath) {
10143
11710
  return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10144
11711
  }
10145
11712
  /**
10146
- * Whether a rulesync command exists whose slug matches `dirName`.
11713
+ * Whether a rulesync command exists whose slug matches `dirName` in any of
11714
+ * the configured input roots.
11715
+ *
11716
+ * `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
11717
+ * `/repo/.rulesync.local`), so commands live directly under
11718
+ * `<sourceTree>/commands/`.
10147
11719
  *
10148
11720
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10149
11721
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10150
11722
  * current command slug is owned by the commands feature, so the skills
10151
11723
  * feature must neither import it as a skill nor delete it as an orphan
10152
- * skill. Once the command is removed from `.rulesync/commands/`, the
10153
- * directory stops matching and the skills feature cleans it up as a regular
10154
- * orphan.
11724
+ * skill. Once the command is removed from every source tree's `commands/`
11725
+ * directory, the directory stops matching and the skills feature cleans
11726
+ * it up as a regular orphan.
10155
11727
  */
10156
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10157
- return (await findFilesByGlobs(join(inputRoot, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"))).some((filePath) => commandSlug(basename(filePath)) === dirName);
11728
+ async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
11729
+ return (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug(basename(filePath)) === dirName);
10158
11730
  }
10159
11731
  //#endregion
10160
11732
  //#region src/features/commands/devin-command.ts
@@ -10844,11 +12416,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10844
12416
  * output is lost — so this warns rather than failing the run the way the
10845
12417
  * Hermes check does, where the two surfaces really do write the same path.
10846
12418
  */
10847
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
12419
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10848
12420
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => basename(command.getRelativeFilePath(), ".md")));
10849
12421
  if (commandNames.size === 0) return;
10850
- const skillsRoot = join(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
10851
- const shadowed = (await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"))).map((filePath) => basename(dirname(filePath))).filter((skillName) => commandNames.has(skillName));
12422
+ const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => basename(dirname(filePath))).filter((skillName) => commandNames.has(skillName));
10852
12423
  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.`);
10853
12424
  }
10854
12425
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -10894,6 +12465,26 @@ function toolSkillSearchRoots(paths) {
10894
12465
  * https://agentskills.io/client-implementation/adding-skills-support
10895
12466
  */
10896
12467
  const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
12468
+ /**
12469
+ * The one spec violation both sides of the conversion report. Generation warns
12470
+ * about it before writing the file; import warns about it because a conformant
12471
+ * client would skip the skill entirely, and a user who never sees that has no
12472
+ * reason to fix it.
12473
+ */
12474
+ const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
12475
+ /**
12476
+ * Report a skill read from disk whose `description` is empty. This lives on the
12477
+ * read rather than on any one tool class because several targets share one
12478
+ * `.agents/skills` tree: which of them reports the skill must not depend on
12479
+ * which target the user happened to enable. Every loader calls it — the two
12480
+ * directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
12481
+ * and the flat-file one — so the form a tool stores its skills in does not
12482
+ * decide whether the problem is reported either.
12483
+ */
12484
+ function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
12485
+ if (typeof description !== "string" || description.length > 0) return;
12486
+ 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.`);
12487
+ }
10897
12488
  function isAgentSkillsInteropRoot(relativeDirPath) {
10898
12489
  return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
10899
12490
  }
@@ -11006,7 +12597,11 @@ var ToolSkill = class extends AiDir {
11006
12597
  const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
11007
12598
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
11008
12599
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
11009
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
12600
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
12601
+ warnOnEmptyLoadedDescription({
12602
+ skillFilePath,
12603
+ description: frontmatter.description
12604
+ });
11010
12605
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
11011
12606
  return {
11012
12607
  outputRoot,
@@ -11131,6 +12726,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
11131
12726
  ...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
11132
12727
  };
11133
12728
  }
12729
+ /** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
12730
+ function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
12731
+ return join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12732
+ }
11134
12733
  /**
11135
12734
  * Collect the normative violations the Agent Skills spec defines for a skill
11136
12735
  * about to be written. These are reported as warnings rather than errors:
@@ -11157,7 +12756,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
11157
12756
  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`);
11158
12757
  if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
11159
12758
  }
11160
- if (description.length === 0) violations.push("`description` is required and must not be empty; conformant clients skip a skill without one");
12759
+ if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
11161
12760
  else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
11162
12761
  const { compatibility } = frontmatter;
11163
12762
  if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
@@ -11276,12 +12875,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
11276
12875
  * directory rather than a same-named project path.
11277
12876
  */
11278
12877
  static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
11279
- const skillPath = join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12878
+ const skillPath = agentSkillFilePath({
12879
+ outputRoot,
12880
+ relativeDirPath,
12881
+ dirName
12882
+ });
11280
12883
  for (const violation of collectAgentSkillViolations({
11281
12884
  frontmatter,
11282
12885
  dirName,
11283
12886
  sourceAllowedTools
11284
- })) warnWithFallback(logger, `${skillPath}: ${violation}`);
12887
+ })) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
11285
12888
  }
11286
12889
  static isTargetedByRulesyncSkill(rulesyncSkill) {
11287
12890
  const targets = rulesyncSkill.getFrontmatter().targets;
@@ -11620,7 +13223,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11620
13223
  static getExtraSharedWritePaths() {
11621
13224
  return getHermesagentSharedConfigWritePaths();
11622
13225
  }
11623
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
13226
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11624
13227
  const commandSlugs = /* @__PURE__ */ new Set();
11625
13228
  const commandOrigins = /* @__PURE__ */ new Map();
11626
13229
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11632,13 +13235,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11632
13235
  commandOrigins.set(slug, origin);
11633
13236
  commandSlugs.add(slug);
11634
13237
  }
11635
- const skillsRoot = join(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
11636
- const skillFiles = await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"));
11637
- const collisions = (await Promise.all(skillFiles.map((path) => RulesyncSkill.fromDir({
11638
- outputRoot: inputRoot,
11639
- relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
11640
- dirName: toPosixPath(relative(skillsRoot, dirname(path)))
11641
- })))).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
13238
+ const skillsByName = /* @__PURE__ */ new Map();
13239
+ for (const rootPath of inputRoots) {
13240
+ const skillsRoot = join(rootPath, SKILLS_FEATURE_SUBDIR);
13241
+ const skillFiles = await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"));
13242
+ const loaded = await Promise.all(skillFiles.map(async (filePath) => {
13243
+ const dirName = toPosixPath(relative(skillsRoot, dirname(filePath)));
13244
+ return {
13245
+ rulesyncSkill: await RulesyncSkill.fromDir({
13246
+ outputRoot: rootPath,
13247
+ relativeDirPath: SKILLS_FEATURE_SUBDIR,
13248
+ dirName
13249
+ }),
13250
+ dirName,
13251
+ rootPath
13252
+ };
13253
+ }));
13254
+ for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
13255
+ }
13256
+ const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
11642
13257
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11643
13258
  }
11644
13259
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11771,6 +13386,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11771
13386
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11772
13387
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11773
13388
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
13389
+ const JUNIE_RULES_DIR_NAME = "rules";
13390
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11774
13391
  //#endregion
11775
13392
  //#region src/features/commands/junie-command.ts
11776
13393
  const JunieCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()) });
@@ -13731,10 +15348,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13731
15348
  global;
13732
15349
  getFactory;
13733
15350
  flattenedCommandNaming;
13734
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
15351
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
13735
15352
  super({
13736
15353
  outputRoot,
13737
- inputRoot,
15354
+ inputRoots,
13738
15355
  dryRun,
13739
15356
  logger
13740
15357
  });
@@ -13749,7 +15366,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13749
15366
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13750
15367
  const factory = this.getFactory(this.toolTarget);
13751
15368
  await factory.class.validateRulesyncCommands?.({
13752
- inputRoot: this.inputRoot,
15369
+ inputRoots: this.inputRoots,
13753
15370
  rulesyncCommands,
13754
15371
  logger: this.logger
13755
15372
  });
@@ -13801,16 +15418,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13801
15418
  return rel;
13802
15419
  }
13803
15420
  /**
13804
- * Implementation of abstract method from FeatureProcessor
13805
- * Load and parse rulesync command files from .rulesync/commands/ directory
15421
+ * Load rulesync command files from a single source-tree's `commands/`
15422
+ * subtree. `sourceTree` is the source tree itself (e.g.
15423
+ * `/repo/.rulesync` or `/repo/.rulesync.local`).
13806
15424
  */
13807
- async loadRulesyncFiles() {
13808
- const basePath = join(this.inputRoot, RulesyncCommand.getSettablePaths().relativeDirPath);
15425
+ async loadRulesyncFilesForRoot(sourceTree) {
15426
+ const treeParent = dirname(sourceTree);
15427
+ const treeName = basename(sourceTree);
15428
+ const treeCommandsDirPath = join(treeName, COMMANDS_FEATURE_SUBDIR);
15429
+ const basePath = join(sourceTree, COMMANDS_FEATURE_SUBDIR);
13809
15430
  const rulesyncCommandPaths = await findFilesByGlobs(join(basePath, "**", "*.md"));
13810
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13811
- outputRoot: this.inputRoot,
15431
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
15432
+ outputRoot: treeParent,
15433
+ relativeDirPath: treeCommandsDirPath,
13812
15434
  relativeFilePath: this.safeRelativePath(basePath, path)
13813
15435
  })));
15436
+ }
15437
+ /**
15438
+ * Implementation of abstract method from FeatureProcessor
15439
+ * Load and parse rulesync command files from every configured input root's
15440
+ * `.rulesync/commands/` directory, merging by relative path so a command
15441
+ * with the same target path from a later root replaces the earlier root's
15442
+ * copy.
15443
+ */
15444
+ async loadRulesyncFiles() {
15445
+ const rulesyncCommands = mergeByCaseInsensitiveIdentity({
15446
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
15447
+ identity: (command) => command.getRelativeFilePath(),
15448
+ artifactName: "command",
15449
+ logger: this.logger
15450
+ });
13814
15451
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13815
15452
  return rulesyncCommands;
13816
15453
  }
@@ -19450,6 +21087,7 @@ function vibeEntryToCanonicalDef(raw) {
19450
21087
  const entry = raw;
19451
21088
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19452
21089
  if (vibeEvent === void 0) return null;
21090
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19453
21091
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19454
21092
  const def = { type: "command" };
19455
21093
  if (typeof entry.command === "string") def.command = entry.command;
@@ -19998,10 +21636,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
19998
21636
  var HooksProcessor = class extends FeatureProcessor {
19999
21637
  toolTarget;
20000
21638
  global;
20001
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
21639
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
20002
21640
  super({
20003
21641
  outputRoot,
20004
- inputRoot,
21642
+ inputRoots,
20005
21643
  dryRun,
20006
21644
  logger
20007
21645
  });
@@ -20011,9 +21649,17 @@ var HooksProcessor = class extends FeatureProcessor {
20011
21649
  this.global = global;
20012
21650
  }
20013
21651
  async loadRulesyncFiles() {
21652
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
21653
+ const sourceTree = await pickLastRootWithFile({
21654
+ inputRoots: this.inputRoots,
21655
+ relativePaths,
21656
+ logger: this.logger,
21657
+ artifactName: "The hooks file"
21658
+ }) ?? this.inputRoots[0];
20014
21659
  try {
20015
21660
  return [await RulesyncHooks.fromFile({
20016
- outputRoot: this.inputRoot,
21661
+ outputRoot: dirname(sourceTree),
21662
+ relativeDirPath: basename(sourceTree),
20017
21663
  validate: true
20018
21664
  })];
20019
21665
  } catch (error) {
@@ -21555,10 +23201,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21555
23201
  getFactory;
21556
23202
  featureOptions;
21557
23203
  global;
21558
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
23204
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
21559
23205
  super({
21560
23206
  outputRoot,
21561
- inputRoot,
23207
+ inputRoots,
21562
23208
  dryRun,
21563
23209
  logger
21564
23210
  });
@@ -21575,11 +23221,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21575
23221
  }
21576
23222
  /**
21577
23223
  * Implementation of abstract method from FeatureProcessor
21578
- * Load and parse rulesync ignore files from .rulesync/ignore/ directory
23224
+ *
23225
+ * Load and parse the rulesync ignore file. `inputRoots[i]` is a source
23226
+ * tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
23227
+ * lives directly inside it. The legacy `.rulesyncignore` is shared at the
23228
+ * project root, so it is intentionally not considered when choosing which
23229
+ * source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
23230
+ * for the chosen tree.
23231
+ *
23232
+ * When multiple input roots are configured, the last root that provides
23233
+ * an ignore file wins entirely (whole-file replacement — no line-level
23234
+ * merge in this slice; see the "Deliberately out of scope" section of
23235
+ * the inputRoots plan for context). If no root has the file, fall back
23236
+ * to the primary root's path so the underlying `RulesyncIgnore.fromFile`
23237
+ * surfaces the same missing-file error it would in the single-root case.
21579
23238
  */
21580
23239
  async loadRulesyncFiles() {
23240
+ const paths = RulesyncIgnore.getSettablePaths();
23241
+ const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
23242
+ const sourceTree = await pickLastRootWithFile({
23243
+ inputRoots: this.inputRoots,
23244
+ relativePaths,
23245
+ logger: this.logger,
23246
+ artifactName: "The ignore file (.aiignore)"
23247
+ }) ?? this.inputRoots[0];
21581
23248
  try {
21582
- return [await RulesyncIgnore.fromFile({ outputRoot: this.inputRoot })];
23249
+ return [await RulesyncIgnore.fromFile({
23250
+ outputRoot: dirname(sourceTree),
23251
+ relativeDirPath: basename(sourceTree)
23252
+ })];
21583
23253
  } catch (error) {
21584
23254
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21585
23255
  return [];
@@ -26614,11 +28284,86 @@ function disabledNamesOf(config) {
26614
28284
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26615
28285
  }
26616
28286
  /**
28287
+ * The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
28288
+ * `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
28289
+ * always POSIX-separated.
28290
+ */
28291
+ const ROVODEV_PROJECT_MCP_CONFIG_POINTER = posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
28292
+ /**
28293
+ * The keys that make an entry in `mcp.json` something Rovo Dev can start:
28294
+ * a local process, or a remote endpoint under either spelling the canonical
28295
+ * config accepts.
28296
+ */
28297
+ const MCP_SERVER_ENDPOINT_KEYS = [
28298
+ "command",
28299
+ "url",
28300
+ "httpUrl"
28301
+ ];
28302
+ function normalizeMcpConfigPathValue(value) {
28303
+ return toPosixPath(value).replace(/^\.\//, "");
28304
+ }
28305
+ /**
28306
+ * Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
28307
+ * and report whether the block gained a value it did not already carry.
28308
+ *
28309
+ * Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
28310
+ * directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
28311
+ * config points at it — the Bitbucket Agentic Pipelines guide documents
28312
+ * registering the server and setting the pointer as two required steps. Global
28313
+ * scope is left alone: there the default already resolves to the file rulesync
28314
+ * writes.
28315
+ *
28316
+ * The pointer names one config rather than merging with the default, so it is
28317
+ * written only when this project actually has a Rovo Dev server to run — a
28318
+ * server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
28319
+ * generated empty, and pointing at it would take away the user's global
28320
+ * servers for this repository in exchange for nothing.
28321
+ *
28322
+ * That condition can also stop holding after the fact, once the last server is
28323
+ * removed from the canonical config or switched off. Rulesync does not take
28324
+ * the pointer back out — it cannot tell its own past value from a user who
28325
+ * typed the same string — but it says so, since the file is now a live setting
28326
+ * that resolves to nothing.
28327
+ *
28328
+ * A pointer the user aimed somewhere else is theirs, not ours: overwriting it
28329
+ * would silently redirect Rovo Dev away from a file they chose. It is named in
28330
+ * a warning instead, because the generated `mcp.json` is unread while it
28331
+ * stands.
28332
+ *
28333
+ * Whatever the outcome, it is logged: writing the pointer turns servers that
28334
+ * were generated-but-never-read into servers Rovo Dev actually spawns, and
28335
+ * points it away from the global MCP config, so it is not something to do
28336
+ * quietly.
28337
+ *
28338
+ * @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
28339
+ * @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
28340
+ */
28341
+ function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
28342
+ if (global) return false;
28343
+ const existing = existingMcp.mcpConfigPath;
28344
+ const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
28345
+ if (!hasLiveServers) {
28346
+ if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${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.`);
28347
+ return false;
28348
+ }
28349
+ if (existing === void 0) {
28350
+ existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
28351
+ logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
28352
+ return true;
28353
+ }
28354
+ if (pointsAtGeneratedFile) return false;
28355
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
28356
+ return false;
28357
+ }
28358
+ /**
26617
28359
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26618
28360
  * `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
26619
- * Rovo Dev actually consults to switch a server off — recomputed from the
26620
- * existing block so user keys (`mcpConfigPath`, `allowedMcpServers`, ...) and
26621
- * disabled names for servers rulesync does not manage survive.
28361
+ * Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
28362
+ * which rulesync authors in project scope when the key is absent and this
28363
+ * project has a server to run (see `applyProjectMcpConfigPointer`). The block
28364
+ * is recomputed from the existing one, so user keys (`allowedMcpServers`,
28365
+ * ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
28366
+ * servers rulesync does not manage all survive.
26622
28367
  */
26623
28368
  var RovodevMcpConfigYaml = class extends ToolFile {
26624
28369
  isDeletable() {
@@ -26751,7 +28496,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26751
28496
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26752
28497
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26753
28498
  else delete existingMcp.disabledMcpServers;
26754
- if (mergedDisabled.length === 0 && existingContent.trim() === "") return [];
28499
+ const wrotePointer = applyProjectMcpConfigPointer({
28500
+ existingMcp,
28501
+ global,
28502
+ hasLiveServers: managedNames.filter((name) => {
28503
+ if (disabledNames.includes(name)) return false;
28504
+ const server = servers[name];
28505
+ return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
28506
+ }).length > 0,
28507
+ logger
28508
+ });
28509
+ if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
26755
28510
  const fileContent = applySharedConfigPatch({
26756
28511
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26757
28512
  feature: "mcp",
@@ -27844,10 +29599,10 @@ var McpProcessor = class extends FeatureProcessor {
27844
29599
  toolTarget;
27845
29600
  global;
27846
29601
  getFactory;
27847
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
29602
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
27848
29603
  super({
27849
29604
  outputRoot,
27850
- inputRoot,
29605
+ inputRoots,
27851
29606
  dryRun,
27852
29607
  logger
27853
29608
  });
@@ -27863,7 +29618,10 @@ var McpProcessor = class extends FeatureProcessor {
27863
29618
  */
27864
29619
  async loadRulesyncFiles() {
27865
29620
  try {
27866
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
29621
+ return [await RulesyncMcp.fromRoots({
29622
+ inputRoots: this.inputRoots,
29623
+ logger: this.logger
29624
+ })];
27867
29625
  } catch (error) {
27868
29626
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27869
29627
  return [];
@@ -29405,27 +31163,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
29405
31163
  return { permission };
29406
31164
  }
29407
31165
  //#endregion
29408
- //#region src/utils/control-characters.ts
29409
- /**
29410
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
29411
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
29412
- * line and paragraph separators, and the plain LRM/RLM marks. A name or value
29413
- * copied out of an untrusted config file, a fetched repository, or a tool's own
29414
- * settings file must never reach the terminal with these intact: they let the
29415
- * text forge log lines, reorder what is printed around them, or inject escape
29416
- * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
29417
- * neutral characters beside them, so they go too — a diagnostic line is not the
29418
- * place to preserve the typography of a right-to-left name.
29419
- */
29420
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
29421
- /**
29422
- * Removes every control character from `text` so it is safe to splice into a
29423
- * log line or other terminal output.
29424
- */
29425
- function stripControlCharacters(text) {
29426
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
29427
- }
29428
- //#endregion
29429
31166
  //#region src/features/permissions/claudecode-permissions.ts
29430
31167
  /**
29431
31168
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -29511,8 +31248,8 @@ function deepMergeRecords(base, patch) {
29511
31248
  *
29512
31249
  * Deliberately NOT listed:
29513
31250
  * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
29514
- * `stripCommandExecutingSandboxPaths` refuses them in both scopes rather than
29515
- * emitting them under `--global`.
31251
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
31252
+ * rather than emitting them under `--global`.
29516
31253
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29517
31254
  * unit is the individual entry's mode, not the settings key, and the same
29518
31255
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29531,6 +31268,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29531
31268
  ["allowAppleEvents"]
29532
31269
  ];
29533
31270
  /**
31271
+ * `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
31272
+ * only from the settings file an organization deploys. Neither file rulesync
31273
+ * writes is that file, so they are dropped in **both** scopes — the `sandbox`
31274
+ * counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
31275
+ * for top-level `Managed` keys.
31276
+ *
31277
+ * Both only ever *narrow* the policy — they stop a lower-scoped file from
31278
+ * re-opening what managed settings blocked — so neither is trust-widening. They
31279
+ * are dropped rather than written for the opposite reason: written into a
31280
+ * project or user file they do nothing at all, and a `sandbox` block that reads
31281
+ * as though it locked the policy to managed values while Claude Code ignores it
31282
+ * is the more dangerous of the two failure modes.
31283
+ *
31284
+ * Import keeps them, unlike the command-executing paths: the value in an
31285
+ * existing `settings.json` was hand-written to be honored somewhere, and
31286
+ * round-tripping it preserves the author's intent for the day it moves into a
31287
+ * managed file. The cost is a warning on every generate until it is removed,
31288
+ * which the refusal message points at.
31289
+ *
31290
+ * @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
31291
+ * — "Scope: `Managed`"; the `network` entry says the same.
31292
+ */
31293
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
31294
+ /**
29534
31295
  * Walks `segments` from `root`, returning the record they name or `undefined` if
29535
31296
  * any step is missing or not a record. Shared by everything below that addresses
29536
31297
  * a `sandbox` path, so a nested path added to one of the tables is actually
@@ -29577,6 +31338,18 @@ function deleteSandboxPath({ target, path }) {
29577
31338
  return true;
29578
31339
  }
29579
31340
  /**
31341
+ * The one warning that names every trust-affecting setting this generate wrote
31342
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
31343
+ * matter, but the "review this as you would a hook" framing only needs saying
31344
+ * once, and repeating it per key buries the reasons in boilerplate.
31345
+ */
31346
+ function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
31347
+ if (entries.length === 0) return;
31348
+ const one = entries.length === 1;
31349
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
31350
+ 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}.`);
31351
+ }
31352
+ /**
29580
31353
  * The `permissions.defaultMode` values that start a session with fewer prompts
29581
31354
  * than the default. `plan` and `default` are absent because they do not widen
29582
31355
  * anything.
@@ -29589,14 +31362,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
29589
31362
  /**
29590
31363
  * The `permissions` fields that widen rather than restrict: a `defaultMode` that
29591
31364
  * removes prompts, and `additionalDirectories`, which moves the
29592
- * working-directory boundary. Warned for the same reason `disableAllHooks` is: a
29593
- * shareable permissions file should not loosen the permission system quietly.
31365
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
31366
+ * a shareable permissions file should not loosen the permission system quietly.
29594
31367
  */
29595
- function warnOnWideningPermissionFields({ fields, relativeFilePath, logger }) {
31368
+ function collectWideningPermissionFields({ fields }) {
31369
+ const entries = [];
29596
31370
  const defaultMode = fields.defaultMode;
29597
- 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'.`);
31371
+ if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
31372
+ label: `permissions.defaultMode: "${defaultMode}"`,
31373
+ reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
31374
+ });
29598
31375
  const additionalDirectories = fields.additionalDirectories;
29599
- 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'.`);
31376
+ if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
31377
+ label: "permissions.additionalDirectories",
31378
+ reason: "moves the boundary of what Claude Code may read and edit outside the project"
31379
+ });
31380
+ return entries;
29600
31381
  }
29601
31382
  /**
29602
31383
  * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
@@ -29613,6 +31394,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
29613
31394
  ["socatPath"]
29614
31395
  ];
29615
31396
  /**
31397
+ * The predicates the "which value actually widens?" tables are built from.
31398
+ * Each names the value that does *not* widen and reports everything else, never
31399
+ * the reverse: the override is authored JSONC, so a key can carry any value at
31400
+ * all, and one Claude Code coerces is still honored. Reporting an off-type value
31401
+ * keeps the warning fail-safe — silence has to mean "this cannot loosen
31402
+ * anything", not "this is not the type the table expected".
31403
+ */
31404
+ const isNotFalse = (value) => value !== false;
31405
+ const isNotTrue = (value) => value !== true;
31406
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
31407
+ const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
31408
+ /**
29616
31409
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
29617
31410
  * they let commands out of it, weaken the isolation it provides, or redirect
29618
31411
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -29632,77 +31425,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29632
31425
  {
29633
31426
  path: ["allowAppleEvents"],
29634
31427
  reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
29635
- widens: (value) => value === true
31428
+ widens: isNotFalse
29636
31429
  },
29637
31430
  {
29638
31431
  path: ["allowUnsandboxedCommands"],
29639
31432
  reason: "controls whether Claude may retry a blocked command outside the sandbox",
29640
- widens: (value) => value !== false
31433
+ widens: isNotFalse
29641
31434
  },
29642
31435
  {
29643
31436
  path: ["autoAllowBashIfSandboxed"],
29644
31437
  reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
29645
- widens: (value) => value !== false
31438
+ widens: isNotFalse
29646
31439
  },
29647
31440
  {
29648
31441
  path: ["enableWeakerNestedSandbox"],
29649
31442
  reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
29650
- widens: (value) => value === true
31443
+ widens: isNotFalse
29651
31444
  },
29652
31445
  {
29653
31446
  path: ["enableWeakerNetworkIsolation"],
29654
31447
  reason: "weakens the sandbox's network isolation on macOS",
29655
- widens: (value) => value === true
31448
+ widens: isNotFalse
29656
31449
  },
29657
31450
  {
29658
31451
  path: ["enabled"],
29659
31452
  reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
29660
- widens: (value) => value === true
31453
+ widens: isNotFalse
29661
31454
  },
29662
31455
  {
29663
31456
  path: ["excludedCommands"],
29664
31457
  reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
29665
- widens: (value) => !Array.isArray(value) || value.length > 0
31458
+ widens: isNonEmptyList
29666
31459
  },
29667
31460
  {
29668
31461
  path: ["filesystem", "allowRead"],
29669
31462
  reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
29670
- widens: (value) => !Array.isArray(value) || value.length > 0
31463
+ widens: isNonEmptyList
29671
31464
  },
29672
31465
  {
29673
31466
  path: ["filesystem", "allowWrite"],
29674
31467
  reason: "adds paths sandboxed commands may write to, outside the working directory",
29675
- widens: (value) => !Array.isArray(value) || value.length > 0
31468
+ widens: isNonEmptyList
29676
31469
  },
29677
31470
  {
29678
31471
  path: ["ignoreViolations"],
29679
31472
  reason: "hides the sandbox violations it names, so a blocked access stops being reported",
29680
- widens: (value) => isPlainRecord(value) ? Object.keys(value).length > 0 : value !== false
31473
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
29681
31474
  },
29682
31475
  {
29683
31476
  path: ["network", "allowAllUnixSockets"],
29684
31477
  reason: "lets sandboxed commands connect to every Unix socket",
29685
- widens: (value) => value === true
31478
+ widens: isNotFalse
29686
31479
  },
29687
31480
  {
29688
31481
  path: ["network", "allowedDomains"],
29689
31482
  reason: "pre-allows domains sandboxed commands may reach without a prompt",
29690
- widens: (value) => !Array.isArray(value) || value.length > 0
31483
+ widens: isNonEmptyList
29691
31484
  },
29692
31485
  {
29693
31486
  path: ["network", "allowLocalBinding"],
29694
31487
  reason: "lets sandboxed commands bind local ports",
29695
- widens: (value) => value === true
31488
+ widens: isNotFalse
29696
31489
  },
29697
31490
  {
29698
31491
  path: ["network", "allowMachLookup"],
29699
31492
  reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
29700
- widens: (value) => !Array.isArray(value) || value.length > 0
31493
+ widens: isNonEmptyList
29701
31494
  },
29702
31495
  {
29703
31496
  path: ["network", "allowUnixSockets"],
29704
31497
  reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
29705
- widens: (value) => !Array.isArray(value) || value.length > 0
31498
+ widens: isNonEmptyList
29706
31499
  },
29707
31500
  {
29708
31501
  path: ["network", "httpProxyPort"],
@@ -29716,11 +31509,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29716
31509
  }
29717
31510
  ];
29718
31511
  /**
29719
- * Warns once per authored `sandbox` path that loosens the sandbox. Nothing is
29720
- * removed — the value is written, just not silently. Called on the filtered
29721
- * `sandbox` so it never claims to be writing a path the scope filters dropped.
31512
+ * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
31513
+ * the values are written, just not silently. Called on the filtered `sandbox`
31514
+ * so it never claims to be writing a path the scope filters dropped.
29722
31515
  */
29723
- function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger }) {
31516
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
31517
+ const entries = [];
29724
31518
  for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29725
31519
  const leaf = path.at(-1);
29726
31520
  if (leaf === void 0) continue;
@@ -29731,38 +31525,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
29731
31525
  if (parent === void 0) continue;
29732
31526
  const value = parent[leaf];
29733
31527
  if (value === void 0 || !widens(value)) continue;
29734
- 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'.`);
31528
+ entries.push({
31529
+ label: `sandbox.${path.join(".")}`,
31530
+ reason
31531
+ });
29735
31532
  }
31533
+ return entries;
29736
31534
  }
31535
+ /** Paths that name an executable Claude Code runs. Refused in both scopes. */
31536
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
31537
+ paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
31538
+ 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.`
31539
+ };
29737
31540
  /**
29738
- * Copy of the authored `sandbox` override with the paths that name an
29739
- * executable removed, warning once per dropped path.
31541
+ * Paths Claude Code honors only from managed settings. Refused in both scopes,
31542
+ * like the command-executing paths, because managed settings are not a file
31543
+ * rulesync writes in either of them.
29740
31544
  */
29741
- function stripCommandExecutingSandboxPaths({ sandbox, relativeFilePath, logger }) {
29742
- const filtered = structuredClone(sandbox);
29743
- for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) {
29744
- if (!deleteSandboxPath({
29745
- target: filtered,
29746
- path
29747
- })) continue;
29748
- 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.`);
29749
- }
29750
- return filtered;
29751
- }
31545
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
31546
+ paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
31547
+ 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.`
31548
+ };
31549
+ /** Paths Claude Code honors only above project scope. Refused at project scope. */
31550
+ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
31551
+ paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
31552
+ 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.`
31553
+ };
29752
31554
  /**
29753
- * Copy of the authored `sandbox` override with the user/managed-only paths
29754
- * removed, warning once per dropped path. Only the override copy is filtered —
29755
- * a value already hand-written in the target file is left untouched, matching
29756
- * the `qwencode` `security.allowPrivateNetworkHooks` precedent.
31555
+ * Copy of the authored `sandbox` override with every path of every passed
31556
+ * refusal removed, warning once per dropped path. Only the override copy is
31557
+ * filtered — a value already hand-written in the target file is left untouched,
31558
+ * matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
29757
31559
  */
29758
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
31560
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
29759
31561
  const filtered = structuredClone(sandbox);
29760
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
31562
+ for (const { paths, warn } of refusals) for (const path of paths) {
29761
31563
  if (!deleteSandboxPath({
29762
31564
  target: filtered,
29763
31565
  path
29764
31566
  })) continue;
29765
- 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.`);
31567
+ logger?.warn(warn({
31568
+ label: `sandbox.${path.join(".")}`,
31569
+ relativeFilePath
31570
+ }));
29766
31571
  }
29767
31572
  return filtered;
29768
31573
  }
@@ -29786,7 +31591,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29786
31591
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29787
31592
  * against cannot slip through a differently-spelled value.
29788
31593
  *
29789
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
31594
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29790
31595
  * value already in the target file is left untouched, which is why the warning
29791
31596
  * points at it.
29792
31597
  *
@@ -29953,6 +31758,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29953
31758
  allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
29954
31759
  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",
29955
31760
  autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
31761
+ claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
31762
+ 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",
31763
+ crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
29956
31764
  disableAllHooks: "controls whether hooks run at all",
29957
31765
  disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
29958
31766
  enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
@@ -29961,17 +31769,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29961
31769
  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",
29962
31770
  extraKnownMarketplaces: "registers plugin marketplace sources",
29963
31771
  httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
31772
+ 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",
29964
31773
  outputStyle: "replaces the system prompt every session runs with",
31774
+ 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",
31775
+ 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",
29965
31776
  skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
29966
- skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
31777
+ skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
31778
+ skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
29967
31779
  };
29968
31780
  /**
29969
31781
  * The keys from the table above that only widen at one particular value.
29970
31782
  * `disableSkillShellExecution: true` turns inline shell execution off, which
29971
- * restricts; the `false` that turns it back on is what a fetched override could
29972
- * use to undo a user setting, so only that value is warned about.
29973
- */
29974
- const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = { disableSkillShellExecution: (value) => value === false };
31783
+ * restricts; anything else re-opens it, and that is what a fetched override
31784
+ * could use to undo a user setting. The rest default to off, so only a value
31785
+ * other than the one that leaves them off is worth a line — and for the
31786
+ * list-valued and map-valued keys, only a non-empty one, since an empty list or
31787
+ * map excludes, announces and overrides nothing.
31788
+ */
31789
+ const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
31790
+ claudeMdExcludes: isNonEmptyList,
31791
+ companyAnnouncements: isNonEmptyList,
31792
+ crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
31793
+ disableSkillShellExecution: isNotTrue,
31794
+ modelOverrides: isNonEmptyMap,
31795
+ remoteControlAtStartup: isNotFalse,
31796
+ skipWebFetchPreflight: isNotFalse
31797
+ };
31798
+ /**
31799
+ * Top-level keys a project-scoped `.claude/settings.json` honors at one value
31800
+ * but ignores at another — the value-level counterpart of
31801
+ * {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
31802
+ * value is dropped at project scope for the same reason a wholly unhonored key
31803
+ * is: committing it would read as a policy that never applies.
31804
+ *
31805
+ * `remoteControlAtStartup` is the only entry whose honored value can be decided
31806
+ * from the value alone. Claude Code honors a `false` from project or local
31807
+ * settings — a repository may turn auto-connect off for its own checkout — but
31808
+ * ignores a `true`, so that a checked-in file cannot turn Remote Control on for
31809
+ * everyone who opens the repository.
31810
+ *
31811
+ * `crossSessionInbound` is on the same documented list but deliberately absent
31812
+ * here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
31813
+ * honored only when it is stricter than the one above it, which no per-value
31814
+ * predicate can decide without reading the user's own settings. It is warned
31815
+ * about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
31816
+ * `--global` its loosening value is honored outright.
31817
+ *
31818
+ * @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
31819
+ */
31820
+ const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
31821
+ ignored: isNotFalse,
31822
+ 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"
31823
+ } };
29975
31824
  /**
29976
31825
  * A key name is authored data that ends up in a log line, so strip the control
29977
31826
  * characters that would let it forge a line or hide the warnings beside it, and
@@ -29999,12 +31848,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
29999
31848
  /**
30000
31849
  * Copy of the authored top-level passthrough with the keys the target file
30001
31850
  * cannot honor removed, warning once per dropped key. Like
30002
- * `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a value
31851
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30003
31852
  * already hand-written in the target file is left untouched, which is why the
30004
31853
  * warning points at it.
30005
31854
  */
30006
31855
  function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30007
31856
  const filtered = {};
31857
+ const trustAffecting = [];
30008
31858
  for (const [key, value] of Object.entries(overrides)) {
30009
31859
  const shown = displayKey(key);
30010
31860
  const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
@@ -30020,11 +31870,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
30020
31870
  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.`);
30021
31871
  continue;
30022
31872
  }
30023
- const widensAtValue = CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey];
30024
- 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'.`);
31873
+ const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
31874
+ if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
31875
+ 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.`);
31876
+ continue;
31877
+ }
31878
+ const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
31879
+ if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
31880
+ label: shown,
31881
+ reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
31882
+ });
30025
31883
  filtered[key] = value;
30026
31884
  }
30027
- return filtered;
31885
+ return {
31886
+ filtered,
31887
+ trustAffecting
31888
+ };
30028
31889
  }
30029
31890
  const CLAUDE_PATH_RULE_ALIASES = {
30030
31891
  Write: "Edit",
@@ -30094,15 +31955,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30094
31955
  config,
30095
31956
  logger
30096
31957
  });
31958
+ const trustAffecting = [];
30097
31959
  const overridePermissions = config.claudecode?.permissions;
30098
31960
  if (overridePermissions && typeof overridePermissions === "object") {
30099
31961
  const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
30100
31962
  const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
30101
- warnOnWideningPermissionFields({
30102
- fields: nonListFields,
30103
- relativeFilePath: paths.relativeFilePath,
30104
- logger
30105
- });
31963
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
30106
31964
  settings.permissions = {
30107
31965
  ...settings.permissions,
30108
31966
  ...nonListFields
@@ -30110,25 +31968,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30110
31968
  }
30111
31969
  const overrideSandbox = config.claudecode?.sandbox;
30112
31970
  if (isPlainRecord(overrideSandbox)) {
30113
- const executableFreeSandbox = stripCommandExecutingSandboxPaths({
31971
+ const honorableSandbox = stripSandboxPaths({
30114
31972
  sandbox: overrideSandbox,
31973
+ refusals: [
31974
+ CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
31975
+ CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
31976
+ ...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
31977
+ ],
30115
31978
  relativeFilePath: paths.relativeFilePath,
30116
31979
  logger
30117
31980
  });
30118
- const scopedSandbox = global ? executableFreeSandbox : stripProjectIgnoredMaskEntries({
30119
- sandbox: stripGlobalOnlySandboxPaths({
30120
- sandbox: executableFreeSandbox,
30121
- relativeFilePath: paths.relativeFilePath,
30122
- logger
30123
- }),
30124
- relativeFilePath: paths.relativeFilePath,
30125
- logger
30126
- });
30127
- warnOnTrustAffectingSandboxPaths({
30128
- sandbox: scopedSandbox,
31981
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
31982
+ sandbox: honorableSandbox,
30129
31983
  relativeFilePath: paths.relativeFilePath,
30130
31984
  logger
30131
31985
  });
31986
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
30132
31987
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
30133
31988
  }
30134
31989
  const overrideTopLevel = {};
@@ -30138,13 +31993,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30138
31993
  if (value === void 0) continue;
30139
31994
  overrideTopLevel[key] = value;
30140
31995
  }
30141
- const scopedTopLevel = stripUnhonoredTopLevelKeys({
31996
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
30142
31997
  overrides: overrideTopLevel,
30143
31998
  global,
30144
31999
  relativeFilePath: paths.relativeFilePath,
30145
32000
  logger
30146
32001
  });
32002
+ trustAffecting.push(...trustAffectingTopLevel);
30147
32003
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
32004
+ warnOnTrustAffectingEntries({
32005
+ entries: trustAffecting,
32006
+ relativeFilePath: paths.relativeFilePath,
32007
+ logger
32008
+ });
30148
32009
  const managedToolNames = managedClaudeToolNames(config);
30149
32010
  const merged = applyPermissions({
30150
32011
  settings,
@@ -35122,11 +36983,8 @@ const TOOL_KEY_TO_CATEGORY = {
35122
36983
  updateConfluencePage: "edit"
35123
36984
  };
35124
36985
  const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
35125
- const OWNED_TOOL_PERMISSION_KEYS = [
35126
- "bash",
35127
- "allowedExternalPaths",
35128
- "default"
35129
- ];
36986
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
36987
+ const MANAGED_BASH_KEYS = ["default", "commands"];
35130
36988
  /**
35131
36989
  * Permissions adapter for Rovo Dev CLI.
35132
36990
  *
@@ -35262,6 +37120,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35262
37120
  }
35263
37121
  };
35264
37122
  /**
37123
+ * Report a `toolPermissions.bash.runInSandbox: false` that this generate is
37124
+ * about to carry through.
37125
+ *
37126
+ * The unmanaged `bash` siblings survive untouched, which for this one means a
37127
+ * generate is not the reset a user might read it as: every command the agent
37128
+ * runs stays outside the sandbox. Rulesync never authors the key and will not
37129
+ * start owning it, but a setting that persists on a committed file and loosens
37130
+ * containment should not do so without saying anything — least of all on the
37131
+ * path where the user just tightened `.rulesync/permissions.*`.
37132
+ */
37133
+ function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
37134
+ const existingBash = existingToolPermissions.bash;
37135
+ if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
37136
+ 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.`);
37137
+ }
37138
+ /**
35265
37139
  * Resolve the `toolPermissions` block to write, merging the generated levels
35266
37140
  * over the existing file. Every other top-level key of `config.yml` is the
35267
37141
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -35269,6 +37143,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35269
37143
  */
35270
37144
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
35271
37145
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
37146
+ warnAboutPreservedSandboxOptOut({
37147
+ existingToolPermissions,
37148
+ filePath,
37149
+ logger
37150
+ });
35272
37151
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
35273
37152
  if (!isRecord$1(existing)) return;
35274
37153
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -35277,9 +37156,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35277
37156
  }
35278
37157
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
35279
37158
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
37159
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
37160
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
35280
37161
  warnAboutDroppedOwnedKeys({
35281
37162
  existingToolPermissions,
35282
37163
  existingTools,
37164
+ existingBash,
35283
37165
  generated,
35284
37166
  filePath,
35285
37167
  logger
@@ -35289,15 +37171,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35289
37171
  delete existingToolPermissions[toolKey];
35290
37172
  delete existingTools[toolKey];
35291
37173
  }
37174
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
35292
37175
  const tools = {
35293
37176
  ...existingTools,
35294
37177
  ...generated.tools
35295
37178
  };
37179
+ const bash = {
37180
+ ...existingBash,
37181
+ ...generated.bash
37182
+ };
35296
37183
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
37184
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
35297
37185
  return {
35298
37186
  ...existingToolPermissions,
35299
37187
  ...generated,
35300
- ...Object.keys(tools).length > 0 ? { tools } : {}
37188
+ ...Object.keys(tools).length > 0 ? { tools } : {},
37189
+ ...Object.keys(bash).length > 0 ? { bash } : {}
35301
37190
  };
35302
37191
  }
35303
37192
  /**
@@ -35306,9 +37195,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35306
37195
  * `/directories` and by an "always allow" answer to a prompt — so their removal
35307
37196
  * must not be silent.
35308
37197
  */
35309
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
37198
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
35310
37199
  const newTools = generated.tools ?? {};
35311
- 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}`)];
37200
+ const newBash = generated.bash ?? {};
37201
+ const droppedKeys = [
37202
+ ...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
37203
+ ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
37204
+ ...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
37205
+ ];
35312
37206
  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.`);
35313
37207
  }
35314
37208
  /**
@@ -37416,10 +39310,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
37416
39310
  var PermissionsProcessor = class extends FeatureProcessor {
37417
39311
  toolTarget;
37418
39312
  global;
37419
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
39313
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
37420
39314
  super({
37421
39315
  outputRoot,
37422
- inputRoot,
39316
+ inputRoots,
37423
39317
  dryRun,
37424
39318
  logger
37425
39319
  });
@@ -37429,9 +39323,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
37429
39323
  this.global = global;
37430
39324
  }
37431
39325
  async loadRulesyncFiles() {
39326
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
39327
+ const sourceTree = await pickLastRootWithFile({
39328
+ inputRoots: this.inputRoots,
39329
+ relativePaths,
39330
+ logger: this.logger,
39331
+ artifactName: "The permissions file"
39332
+ }) ?? this.inputRoots[0];
37432
39333
  try {
37433
39334
  return [await RulesyncPermissions.fromFile({
37434
- outputRoot: this.inputRoot,
39335
+ outputRoot: dirname(sourceTree),
39336
+ relativeDirPath: basename(sourceTree),
37435
39337
  validate: true
37436
39338
  })];
37437
39339
  } catch (error) {
@@ -37579,9 +39481,13 @@ var SimulatedSkill = class extends ToolSkill {
37579
39481
  const skillDirPath = join(outputRoot, actualRelativeDirPath, dirName);
37580
39482
  const skillFilePath = join(skillDirPath, SKILL_FILE_NAME);
37581
39483
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
37582
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
39484
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
37583
39485
  const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
37584
39486
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
39487
+ warnOnEmptyLoadedDescription({
39488
+ skillFilePath,
39489
+ description: result.data.description
39490
+ });
37585
39491
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
37586
39492
  return {
37587
39493
  outputRoot,
@@ -37855,13 +39761,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37855
39761
  //#region src/types/dir-feature-processor.ts
37856
39762
  var DirFeatureProcessor = class {
37857
39763
  outputRoot;
37858
- inputRoot;
39764
+ /**
39765
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
39766
+ * is a source tree itself — the directory that directly contains
39767
+ * feature subdirectories (`rules/`, `skills/`, …) and single-file
39768
+ * features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
39769
+ * when two trees supply the same relative path. Defaults to
39770
+ * `[join(process.cwd(), ".rulesync")]`.
39771
+ *
39772
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
39773
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
39774
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
39775
+ * reaches a processor.
39776
+ */
39777
+ inputRoots;
37859
39778
  dryRun;
37860
39779
  avoidBlockScalars;
37861
39780
  logger;
37862
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
39781
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37863
39782
  this.outputRoot = outputRoot;
37864
- this.inputRoot = inputRoot;
39783
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
37865
39784
  this.dryRun = dryRun;
37866
39785
  this.avoidBlockScalars = avoidBlockScalars;
37867
39786
  this.logger = logger;
@@ -40120,9 +42039,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
40120
42039
  * slug is owned by the commands feature: it must not be imported as a
40121
42040
  * skill nor deleted as an orphan skill.
40122
42041
  */
40123
- static async isDirOwned({ dirName, inputRoot }) {
42042
+ static async isDirOwned({ dirName, inputRoots }) {
40124
42043
  return !await rulesyncCommandSlugExists({
40125
- inputRoot,
42044
+ inputRoots,
40126
42045
  dirName
40127
42046
  });
40128
42047
  }
@@ -40172,7 +42091,11 @@ const FactorydroidSkillFrontmatterSchema = z.looseObject({
40172
42091
  "user-invocable": z.optional(z.boolean()),
40173
42092
  "disable-model-invocation": z.optional(z.boolean()),
40174
42093
  enabled: z.optional(z.boolean()),
40175
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
42094
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
42095
+ license: z.optional(z.unknown()),
42096
+ compatibility: z.optional(z.unknown()),
42097
+ metadata: z.optional(z.unknown()),
42098
+ version: z.optional(z.unknown())
40176
42099
  });
40177
42100
  /**
40178
42101
  * Represents a Factory Droid skill directory.
@@ -40225,16 +42148,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40225
42148
  };
40226
42149
  }
40227
42150
  toRulesyncSkill() {
40228
- const frontmatter = this.getFrontmatter();
40229
- const factorydroidBlock = {
40230
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
40231
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
40232
- ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
40233
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
40234
- };
42151
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
40235
42152
  const rulesyncFrontmatter = {
40236
- name: frontmatter.name,
40237
- description: frontmatter.description,
42153
+ name,
42154
+ description,
40238
42155
  targets: ["*"],
40239
42156
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
40240
42157
  };
@@ -40261,13 +42178,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40261
42178
  rootFrontmatter: rulesyncFrontmatter,
40262
42179
  section: factorydroidSection
40263
42180
  });
42181
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
40264
42182
  const factorydroidFrontmatter = {
40265
42183
  name: rulesyncFrontmatter.name,
40266
42184
  description: rulesyncFrontmatter.description,
42185
+ ...section,
40267
42186
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
40268
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
40269
- ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
40270
- ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
42187
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
40271
42188
  };
40272
42189
  return new FactorydroidSkill({
40273
42190
  outputRoot,
@@ -40679,7 +42596,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40679
42596
  }
40680
42597
  }
40681
42598
  static getSettablePaths(_options) {
40682
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
42599
+ return {
42600
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
42601
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
42602
+ };
40683
42603
  }
40684
42604
  getFrontmatter() {
40685
42605
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -41095,7 +43015,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
41095
43015
  }
41096
43016
  static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
41097
43017
  const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
41098
- const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
43018
+ const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
41099
43019
  const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
41100
43020
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
41101
43021
  const fileName = basename(relativeFilePath, extname(relativeFilePath));
@@ -41105,6 +43025,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
41105
43025
  name: result.data.name ?? fileName,
41106
43026
  description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
41107
43027
  };
43028
+ warnOnEmptyLoadedDescription({
43029
+ skillFilePath: filePath,
43030
+ description: normalizedFrontmatter.description
43031
+ });
41108
43032
  return new KimiCodeSkill({
41109
43033
  outputRoot,
41110
43034
  relativeDirPath,
@@ -42108,7 +44032,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
42108
44032
  static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
42109
44033
  const skillFilePath = join(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
42110
44034
  try {
42111
- const { frontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
44035
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
42112
44036
  return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
42113
44037
  } catch {
42114
44038
  return true;
@@ -42477,8 +44401,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
42477
44401
  const fullPath = join(this.outputRoot, this.relativeDirPath);
42478
44402
  const resolvedFull = resolve(fullPath);
42479
44403
  const resolvedBase = resolve(this.outputRoot);
42480
- const rel = relative(resolvedBase, resolvedFull);
42481
- if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
44404
+ if (pathEscapesRoot(relative(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
42482
44405
  return fullPath;
42483
44406
  }
42484
44407
  getRelativePathFromCwd() {
@@ -42841,9 +44764,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42841
44764
  * slug is owned by the commands feature: it must not be imported as a
42842
44765
  * skill nor deleted as an orphan skill.
42843
44766
  */
42844
- static async isDirOwned({ dirName, inputRoot }) {
44767
+ static async isDirOwned({ dirName, inputRoots }) {
42845
44768
  return !await rulesyncCommandSlugExists({
42846
- inputRoot,
44769
+ inputRoots,
42847
44770
  dirName
42848
44771
  });
42849
44772
  }
@@ -43401,10 +45324,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43401
45324
  toolTarget;
43402
45325
  global;
43403
45326
  getFactory;
43404
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
45327
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
43405
45328
  super({
43406
45329
  outputRoot,
43407
- inputRoot,
45330
+ inputRoots,
43408
45331
  dryRun,
43409
45332
  avoidBlockScalars: toolTarget === "cursor",
43410
45333
  logger
@@ -43442,39 +45365,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43442
45365
  return rulesyncSkills;
43443
45366
  }
43444
45367
  /**
43445
- * Implementation of abstract method from DirFeatureProcessor
43446
- * Load and parse rulesync skill directories from .rulesync/skills/ directory
43447
- * and also from .rulesync/skills/.curated/ for remote skills.
43448
- * Local skills take precedence over curated skills with the same name.
45368
+ * Load rulesync skill directories from a single source-tree's `skills/`
45369
+ * (and `skills/.curated/`) subtree. `sourceTree` is the source tree
45370
+ * itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
45371
+ * local skills take precedence over curated skills with the same name.
43449
45372
  */
43450
- async loadRulesyncDirs() {
43451
- const localDirNames = [...await getLocalSkillDirNames(this.inputRoot)];
45373
+ async loadRulesyncDirsForRoot(sourceTree) {
45374
+ const treeParent = dirname(sourceTree);
45375
+ const treeName = basename(sourceTree);
45376
+ const treeSkillsDirPath = join(treeName, SKILLS_FEATURE_SUBDIR);
45377
+ const treeCuratedSkillsDirPath = join(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
45378
+ const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
43452
45379
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
43453
- outputRoot: this.inputRoot,
45380
+ outputRoot: treeParent,
45381
+ relativeDirPath: treeSkillsDirPath,
43454
45382
  dirName,
43455
45383
  global: this.global
43456
45384
  })));
43457
- const localSkillNames = new Set(localDirNames);
43458
- const curatedDirPath = join(this.inputRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
45385
+ const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
45386
+ const curatedDirPath = join(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
43459
45387
  let curatedSkills = [];
43460
45388
  if (await directoryExists(curatedDirPath)) {
43461
45389
  const nonConflicting = (await findFilesByGlobs(join(curatedDirPath, "*"), { type: "dir" })).map((path) => basename(path)).filter((name) => {
43462
- if (localSkillNames.has(name)) {
43463
- this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
43464
- return false;
43465
- }
43466
- return true;
45390
+ const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
45391
+ if (spellings === void 0) return true;
45392
+ if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
45393
+ else this.logger.warn(formatCuratedCaseCollisionWarning({
45394
+ artifactKind: "skill",
45395
+ entryNoun: "skill",
45396
+ treeDirPath: treeSkillsDirPath,
45397
+ curatedSpelling: name,
45398
+ localSpellings: spellings
45399
+ }));
45400
+ return false;
43467
45401
  });
43468
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
43469
45402
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
43470
- outputRoot: this.inputRoot,
43471
- relativeDirPath: curatedRelativeDirPath,
45403
+ outputRoot: treeParent,
45404
+ relativeDirPath: treeCuratedSkillsDirPath,
43472
45405
  dirName,
43473
45406
  global: this.global
43474
45407
  })));
43475
45408
  }
43476
- const allSkills = [...localSkills, ...curatedSkills];
43477
- this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills (${localSkills.length} local, ${curatedSkills.length} curated)`);
45409
+ return [...localSkills, ...curatedSkills];
45410
+ }
45411
+ /**
45412
+ * Implementation of abstract method from DirFeatureProcessor.
45413
+ *
45414
+ * Load and parse rulesync skill directories from every configured input
45415
+ * root's `.rulesync/skills/` tree (each root also honours its own
45416
+ * `.curated/` subdirectory). When two roots supply a skill with the same
45417
+ * directory name, the later root's skill replaces the earlier root's copy
45418
+ * atomically (companion files included) — an overlay always ships a whole
45419
+ * skill directory, never a partial patch.
45420
+ */
45421
+ async loadRulesyncDirs() {
45422
+ const allSkills = mergeByCaseInsensitiveIdentity({
45423
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
45424
+ identity: (skill) => skill.getDirName(),
45425
+ artifactName: "skill",
45426
+ logger: this.logger
45427
+ });
45428
+ this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
43478
45429
  return allSkills;
43479
45430
  }
43480
45431
  /**
@@ -43490,7 +45441,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43490
45441
  }) : [];
43491
45442
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
43492
45443
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
43493
- const seenSkillNames = /* @__PURE__ */ new Set();
45444
+ const claimedSkillNames = new ClaimedIdentities();
45445
+ const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
45446
+ const skillName = skill.getImportIdentity();
45447
+ const claimed = claimedSkillNames.claim({
45448
+ identity: skillName,
45449
+ source: relativeDirPath
45450
+ });
45451
+ if (claimed === null) return true;
45452
+ 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.`);
45453
+ return false;
45454
+ };
43494
45455
  const toolSkills = [];
43495
45456
  for (const root of roots) {
43496
45457
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -43506,54 +45467,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43506
45467
  outputRoot: rootOutputRoot,
43507
45468
  relativeDirPath,
43508
45469
  dirName,
43509
- inputRoot: this.inputRoot
45470
+ inputRoots: this.inputRoots
43510
45471
  })) continue;
43511
45472
  ownedDirNames.push(dirName);
43512
45473
  }
43513
45474
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
45475
+ const sourcePath = join(relativeDirPath, dirName);
43514
45476
  try {
43515
- return await factory.class.fromDir({
43516
- outputRoot: rootOutputRoot,
43517
- relativeDirPath,
43518
- dirName,
43519
- global: this.global
43520
- });
45477
+ return {
45478
+ skill: await factory.class.fromDir({
45479
+ outputRoot: rootOutputRoot,
45480
+ relativeDirPath,
45481
+ dirName,
45482
+ global: this.global
45483
+ }),
45484
+ sourcePath
45485
+ };
43521
45486
  } catch (error) {
43522
45487
  if (!isLenientRoot) throw error;
43523
- this.logger.warn(`Skipping ${join(relativeDirPath, dirName)}: ${formatError(error)}`);
45488
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43524
45489
  return null;
43525
45490
  }
43526
- }))).filter((skill) => skill !== null);
43527
- for (const skill of directorySkills) {
43528
- const skillName = skill.getImportIdentity();
43529
- if (seenSkillNames.has(skillName)) continue;
43530
- seenSkillNames.add(skillName);
43531
- toolSkills.push(skill);
43532
- }
45491
+ }))).filter((loaded) => loaded !== null);
45492
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
45493
+ skill,
45494
+ relativeDirPath,
45495
+ sourcePath
45496
+ })) toolSkills.push(skill);
43533
45497
  if (!factory.class.fromFlatFile) continue;
43534
45498
  const fromFlatFile = factory.class.fromFlatFile;
43535
45499
  const directoryStems = new Set(ownedDirNames);
43536
45500
  const flatFilePaths = (await findFilesByGlobs(join(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has(basename(filePath, ".md")));
43537
45501
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
45502
+ const sourcePath = join(relativeDirPath, basename(filePath));
43538
45503
  try {
43539
- return await fromFlatFile({
43540
- outputRoot: rootOutputRoot,
43541
- relativeDirPath,
43542
- relativeFilePath: basename(filePath),
43543
- global: this.global
43544
- });
45504
+ return {
45505
+ skill: await fromFlatFile({
45506
+ outputRoot: rootOutputRoot,
45507
+ relativeDirPath,
45508
+ relativeFilePath: basename(filePath),
45509
+ global: this.global
45510
+ }),
45511
+ sourcePath
45512
+ };
43545
45513
  } catch (error) {
43546
45514
  if (!isLenientRoot) throw error;
43547
- this.logger.warn(`Skipping ${join(relativeDirPath, basename(filePath))}: ${formatError(error)}`);
45515
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43548
45516
  return null;
43549
45517
  }
43550
- }))).filter((skill) => skill !== null);
43551
- for (const skill of flatSkills) {
43552
- const skillName = skill.getImportIdentity();
43553
- if (seenSkillNames.has(skillName)) continue;
43554
- seenSkillNames.add(skillName);
43555
- toolSkills.push(skill);
43556
- }
45518
+ }))).filter((loaded) => loaded !== null);
45519
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
45520
+ skill,
45521
+ relativeDirPath,
45522
+ sourcePath
45523
+ })) toolSkills.push(skill);
43557
45524
  }
43558
45525
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43559
45526
  return toolSkills;
@@ -43583,7 +45550,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43583
45550
  outputRoot: this.outputRoot,
43584
45551
  relativeDirPath: root,
43585
45552
  dirName,
43586
- inputRoot: this.inputRoot
45553
+ inputRoots: this.inputRoots
43587
45554
  })) continue;
43588
45555
  const toolSkill = factory.class.forDeletion({
43589
45556
  outputRoot: this.outputRoot,
@@ -46341,6 +48308,7 @@ const JunieSubagentFrontmatterSchema = z.looseObject({
46341
48308
  disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])),
46342
48309
  mcpServers: z.optional(z.union([z.string(), z.array(z.string())])),
46343
48310
  model: z.optional(z.string()),
48311
+ permissionMode: z.optional(z.string()),
46344
48312
  reasoningLevel: z.optional(z.string()),
46345
48313
  maxTurns: z.optional(z.number()),
46346
48314
  skills: z.optional(z.union([z.string(), z.array(z.string())])),
@@ -47400,7 +49368,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
47400
49368
  const paths = this.getSettablePaths({ global });
47401
49369
  const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
47402
49370
  const fileContent = await readFileContent(filePath);
47403
- const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
49371
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
47404
49372
  const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
47405
49373
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
47406
49374
  return new ReasonixSubagent({
@@ -47429,7 +49397,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
47429
49397
  static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
47430
49398
  const filePath = join(outputRoot, relativeDirPath, relativeFilePath);
47431
49399
  try {
47432
- const { frontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
49400
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
47433
49401
  return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
47434
49402
  } catch {
47435
49403
  return false;
@@ -48321,14 +50289,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
48321
50289
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
48322
50290
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
48323
50291
  });
50292
+ /**
50293
+ * Stands in for a discovery root when a subagent came from a tool's own config
50294
+ * file rather than a directory (see `loadAdditionalImportFiles`). The angle
50295
+ * brackets keep it from ever matching a real relative directory path.
50296
+ */
50297
+ const INLINE_SOURCE = "<inline>";
50298
+ /**
50299
+ * The single "root" of the post-conversion output guard, which de-duplicates
50300
+ * `.rulesync/subagents/` paths rather than discovery roots.
50301
+ */
50302
+ const OUTPUT_SOURCE = "<output>";
48324
50303
  var SubagentsProcessor = class extends FeatureProcessor {
48325
50304
  toolTarget;
48326
50305
  global;
48327
50306
  getFactory;
48328
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
50307
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
48329
50308
  super({
48330
50309
  outputRoot,
48331
- inputRoot,
50310
+ inputRoots,
48332
50311
  dryRun,
48333
50312
  logger
48334
50313
  });
@@ -48374,24 +50353,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
48374
50353
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
48375
50354
  }
48376
50355
  const uniqueRulesyncSubagents = [];
48377
- const seenOutputPaths = /* @__PURE__ */ new Set();
50356
+ const claimedOutputPaths = new ClaimedIdentities();
48378
50357
  for (const rulesyncSubagent of rulesyncSubagents) {
48379
50358
  const outputPath = join(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
48380
- if (seenOutputPaths.has(outputPath)) {
48381
- this.logger.warn(`Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.`);
50359
+ const claimed = claimedOutputPaths.claim({
50360
+ identity: outputPath,
50361
+ source: OUTPUT_SOURCE
50362
+ });
50363
+ if (claimed !== null) {
50364
+ 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.`);
48382
50365
  continue;
48383
50366
  }
48384
- seenOutputPaths.add(outputPath);
48385
50367
  uniqueRulesyncSubagents.push(rulesyncSubagent);
48386
50368
  }
48387
50369
  return uniqueRulesyncSubagents;
48388
50370
  }
48389
50371
  /**
48390
- * Implementation of abstract method from Processor
48391
- * Load and parse rulesync subagent files from .rulesync/subagents/ directory
50372
+ * Load subagent files from a single source-tree's `subagents/` subtree.
50373
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
50374
+ * `/repo/.rulesync.local`).
48392
50375
  */
48393
- async loadRulesyncFiles() {
48394
- const subagentsDir = join(this.inputRoot, RulesyncSubagent.getSettablePaths().relativeDirPath);
50376
+ async loadRulesyncFilesForRoot(sourceTree) {
50377
+ const treeParent = dirname(sourceTree);
50378
+ const treeName = basename(sourceTree);
50379
+ const treeSubagentsDirPath = join(treeName, SUBAGENTS_FEATURE_SUBDIR);
50380
+ const subagentsDir = join(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
48395
50381
  if (!await directoryExists(subagentsDir)) {
48396
50382
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
48397
50383
  return [];
@@ -48407,7 +50393,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
48407
50393
  const filepath = join(subagentsDir, mdFile);
48408
50394
  try {
48409
50395
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
48410
- outputRoot: this.inputRoot,
50396
+ outputRoot: treeParent,
50397
+ relativeDirPath: treeSubagentsDirPath,
48411
50398
  relativeFilePath: mdFile,
48412
50399
  validate: true
48413
50400
  });
@@ -48418,8 +50405,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
48418
50405
  continue;
48419
50406
  }
48420
50407
  }
50408
+ return rulesyncSubagents;
50409
+ }
50410
+ /**
50411
+ * Implementation of abstract method from Processor
50412
+ * Load and parse rulesync subagent files from every configured input root's
50413
+ * `.rulesync/subagents/` directory, merging by relative file path so a
50414
+ * subagent with the same target path from a later root replaces the
50415
+ * earlier root's copy.
50416
+ */
50417
+ async loadRulesyncFiles() {
50418
+ const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
50419
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
50420
+ identity: (subagent) => subagent.getRelativeFilePath(),
50421
+ artifactName: "subagent",
50422
+ logger: this.logger
50423
+ });
48421
50424
  if (rulesyncSubagents.length === 0) {
48422
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
50425
+ this.logger.debug(`No valid subagents found`);
48423
50426
  return [];
48424
50427
  }
48425
50428
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -48434,7 +50437,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
48434
50437
  const paths = factory.class.getSettablePaths({ global: this.global });
48435
50438
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
48436
50439
  const toolSubagents = [];
48437
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
50440
+ const claimedRelativeFilePaths = new ClaimedIdentities();
48438
50441
  for (const root of roots) {
48439
50442
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
48440
50443
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -48473,37 +50476,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
48473
50476
  relativeFilePath: toRelativeFilePath(path),
48474
50477
  global: this.global
48475
50478
  })));
48476
- const deduped = [];
48477
- for (const subagent of loaded) {
48478
- const key = subagent.getImportIdentity();
48479
- if (seenRelativeFilePaths.has(key)) {
48480
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping the one from a higher-precedence directory and ignoring this copy.`);
48481
- continue;
48482
- }
48483
- seenRelativeFilePaths.add(key);
48484
- deduped.push(subagent);
48485
- }
48486
- toolSubagents.push(...deduped);
50479
+ toolSubagents.push(...this.claimStandaloneSubagents({
50480
+ loaded,
50481
+ dirPath,
50482
+ claimedRelativeFilePaths
50483
+ }));
48487
50484
  }
48488
50485
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
48489
50486
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
48490
50487
  outputRoot: this.outputRoot,
48491
50488
  global: this.global
48492
50489
  });
48493
- for (const subagent of additionalSubagents) {
48494
- const key = subagent.getImportIdentity();
48495
- if (seenRelativeFilePaths.has(key)) {
48496
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping the standalone file and ignoring the inline copy.`);
48497
- continue;
48498
- }
48499
- seenRelativeFilePaths.add(key);
48500
- toolSubagents.push(subagent);
48501
- }
50490
+ toolSubagents.push(...this.claimInlineSubagents({
50491
+ additionalSubagents,
50492
+ claimedRelativeFilePaths
50493
+ }));
48502
50494
  }
48503
50495
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
48504
50496
  return toolSubagents;
48505
50497
  }
48506
50498
  /**
50499
+ * Keeps the subagents from one discovery root whose import identity is still
50500
+ * unclaimed, warning about each copy that loses. Split out of
50501
+ * `loadToolFiles` so the two de-duplication passes stay readable side by
50502
+ * side (and so that method stays within the linter's complexity budget).
50503
+ *
50504
+ * When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
50505
+ * plus `.agents/`), two roots can hold a subagent with the same relative
50506
+ * path. Downstream conversion keys by that path, so a later one would
50507
+ * silently overwrite an earlier one. Warn instead of failing, keeping the
50508
+ * earlier (higher-precedence) root's file.
50509
+ */
50510
+ claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
50511
+ const deduped = [];
50512
+ for (const subagent of loaded) {
50513
+ const key = subagent.getImportIdentity();
50514
+ const claimed = claimedRelativeFilePaths.claim({
50515
+ identity: key,
50516
+ source: dirPath
50517
+ });
50518
+ if (claimed === null) {
50519
+ deduped.push(subagent);
50520
+ continue;
50521
+ }
50522
+ const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
50523
+ 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.`);
50524
+ }
50525
+ return deduped;
50526
+ }
50527
+ /**
50528
+ * The same claim-or-warn pass for subagents defined inline in a tool's own
50529
+ * config file (see `loadAdditionalImportFiles`), which are scanned after
50530
+ * every standalone file so a Markdown file of the same name wins.
50531
+ */
50532
+ claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
50533
+ const deduped = [];
50534
+ for (const subagent of additionalSubagents) {
50535
+ const key = subagent.getImportIdentity();
50536
+ const claimed = claimedRelativeFilePaths.claim({
50537
+ identity: key,
50538
+ source: INLINE_SOURCE
50539
+ });
50540
+ if (claimed === null) {
50541
+ deduped.push(subagent);
50542
+ continue;
50543
+ }
50544
+ const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
50545
+ 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.`);
50546
+ }
50547
+ return deduped;
50548
+ }
50549
+ /**
48507
50550
  * Implementation of abstract method from FeatureProcessor
48508
50551
  * Return the tool targets that this processor supports
48509
50552
  */
@@ -51433,17 +53476,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
51433
53476
  * Rule generator for JetBrains Junie AI coding agent
51434
53477
  *
51435
53478
  * Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
51436
- * resolves project guidelines **first-match-wins**: `.junie/AGENTS.md` → root
51437
- * `AGENTS.md` legacy `.junie/guidelines.md` / `.junie/guidelines/`. Only the
51438
- * first match is loaded, it documents no `@`-reference or file-inclusion
51439
- * mechanism, and no `.junie/memories/` read path exists so non-root rules
51440
- * are folded into the single root `.junie/AGENTS.md` by the RulesProcessor
51441
- * (`nonRoot` is `undefined`, mirroring the warp / deepagents
51442
- * targets; decision recorded in issue #2211). The legacy
53479
+ * resolves project guidelines in this order: `.junie/AGENTS.md` → root
53480
+ * `AGENTS.md` **combined with `.junie/playbook.md` and every
53481
+ * `.junie/rules/*.md`** legacy `.junie/guidelines.md` / `.junie/guidelines/`.
53482
+ * The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
53483
+ * is present: that file "is used exclusively and no other guidelines files are
53484
+ * combined with it". So emitting `.junie/rules/*.md` next to the root file
53485
+ * rulesync writes would produce files Junie never reads, and moving the root
53486
+ * output to project-root `AGENTS.md` would both change every existing output
53487
+ * path and collide with the `agentsmd` target. Non-root rules therefore stay
53488
+ * folded into the single root `.junie/AGENTS.md` by the RulesProcessor
53489
+ * (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
53490
+ * fold is lossless, since Junie loads that one file in full. The original
53491
+ * rationale for this shape was recorded in issue #2211 and re-confirmed
53492
+ * against the 2026-08-21 docs revision in issue #2728. The legacy
51443
53493
  * `.junie/guidelines.md` is still accepted as an import fallback, but
51444
53494
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
51445
53495
  * without frontmatter requirements.
51446
53496
  *
53497
+ * The multi-file branch is still read on **import**, though: a repo that
53498
+ * authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
53499
+ * layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
53500
+ * first `rulesync import` finds — declares those paths as `importOnlyRoots`
53501
+ * and imports them as non-root rules. They are never written back there:
53502
+ * generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
53503
+ * folded root file carries the same content.
53504
+ *
51447
53505
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
51448
53506
  * these user-scope guidelines with the project guidelines (both are included
51449
53507
  * and marked clearly).
@@ -51465,6 +53523,14 @@ var JunieRule = class JunieRule extends ToolRule {
51465
53523
  alternativeRoots: [{
51466
53524
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
51467
53525
  relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
53526
+ }],
53527
+ importOnlyRoots: [{
53528
+ relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
53529
+ onlyWhenRootAbsent: true
53530
+ }, {
53531
+ relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
53532
+ relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
53533
+ onlyWhenRootAbsent: true
51468
53534
  }]
51469
53535
  };
51470
53536
  }
@@ -51475,7 +53541,7 @@ var JunieRule = class JunieRule extends ToolRule {
51475
53541
  static isRootRelativeFilePath(relativeFilePath) {
51476
53542
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
51477
53543
  }
51478
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
53544
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
51479
53545
  if (global) {
51480
53546
  const paths = this.getSettablePaths({ global: true });
51481
53547
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -51489,7 +53555,8 @@ var JunieRule = class JunieRule extends ToolRule {
51489
53555
  root: true
51490
53556
  });
51491
53557
  }
51492
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
53558
+ const settablePaths = this.getSettablePaths();
53559
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
51493
53560
  const relativePath = join(relativeDirPath, relativeFilePath);
51494
53561
  const fileContent = await readFileContent(join(outputRoot, relativePath));
51495
53562
  return new JunieRule({
@@ -51498,7 +53565,7 @@ var JunieRule = class JunieRule extends ToolRule {
51498
53565
  relativeFilePath,
51499
53566
  fileContent,
51500
53567
  validate,
51501
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
53568
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
51502
53569
  });
51503
53570
  }
51504
53571
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53850,10 +55917,23 @@ const defaultGetFactory = (target) => {
53850
55917
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53851
55918
  return factory;
53852
55919
  };
53853
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53854
- const primaryFilePaths = await findFilesByGlobs(primaryGlob);
55920
+ /**
55921
+ * How many skipped import-only paths a single warning names before it
55922
+ * summarizes the rest. Keeps one line readable when a rules directory holds
55923
+ * dozens of files.
55924
+ */
55925
+ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
55926
+ /**
55927
+ * Fall back to a tool's legacy roots when its primary root file is absent.
55928
+ *
55929
+ * The primary hits are passed in rather than globbed here, so that callers
55930
+ * which need "the root file Rulesync generates" — rather than "whatever root
55931
+ * the tool will read" — can keep the two apart. A legacy root is a file
55932
+ * Rulesync reads but never writes, and the difference matters to them.
55933
+ */
55934
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
53855
55935
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53856
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
55936
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53857
55937
  return [];
53858
55938
  };
53859
55939
  var RulesProcessor = class extends FeatureProcessor {
@@ -53865,10 +55945,10 @@ var RulesProcessor = class extends FeatureProcessor {
53865
55945
  getFactory;
53866
55946
  skills;
53867
55947
  featureOptions;
53868
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
55948
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
53869
55949
  super({
53870
55950
  outputRoot,
53871
- inputRoot,
55951
+ inputRoots,
53872
55952
  dryRun,
53873
55953
  logger
53874
55954
  });
@@ -54243,25 +56323,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
54243
56323
  claimedBy.set(target.toLowerCase(), source);
54244
56324
  continue;
54245
56325
  }
54246
- this.logger.warn(`Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
56326
+ this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters(join(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
54247
56327
  }
54248
56328
  return rulesyncRules;
54249
56329
  }
54250
56330
  /**
54251
- * Implementation of abstract method from FeatureProcessor
54252
- * Load and parse rulesync rule files from .rulesync/rules/ directory
56331
+ * Load rulesync rule files from a single source-tree's `rules/` (and
56332
+ * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
56333
+ * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
56334
+ *
56335
+ * Intra-tree behavior — the local-wins-over-curated rule and the
56336
+ * case-insensitive collision handling — is preserved from the previous
56337
+ * single-root implementation. See `loadRulesyncFiles` for how the
56338
+ * per-root results are combined into the effective set.
54253
56339
  */
54254
- async loadRulesyncFiles() {
54255
- const rulesyncOutputRoot = join(this.inputRoot, RULESYNC_RULES_RELATIVE_DIR_PATH);
54256
- const curatedOutputRoot = join(this.inputRoot, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH);
56340
+ async loadRulesyncFilesForRoot(sourceTree) {
56341
+ const treeParent = dirname(sourceTree);
56342
+ const treeName = basename(sourceTree);
56343
+ const treeRulesDirPath = join(treeName, RULES_FEATURE_SUBDIR);
56344
+ const rulesyncOutputRoot = join(sourceTree, RULES_FEATURE_SUBDIR);
56345
+ const curatedOutputRoot = join(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
54257
56346
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs(join(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs(join(curatedOutputRoot, "**", "*.md"))]);
54258
56347
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
54259
56348
  const localFiles = files.filter((file) => !relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`));
54260
- const localRelativePaths = new Set(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
56349
+ const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
54261
56350
  const curatedFiles = files.filter((file) => relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`)).map((file) => ({
54262
56351
  file,
54263
56352
  relativeFilePath: relative(curatedOutputRoot, file)
54264
- })).filter(({ relativeFilePath }) => !localRelativePaths.has(relativeFilePath));
56353
+ })).filter(({ relativeFilePath }) => {
56354
+ const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
56355
+ if (spellings === void 0) return true;
56356
+ if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
56357
+ artifactKind: "rule",
56358
+ entryNoun: "file",
56359
+ treeDirPath: treeRulesDirPath,
56360
+ curatedSpelling: join(".curated", relativeFilePath),
56361
+ localSpellings: spellings
56362
+ }));
56363
+ return false;
56364
+ });
54265
56365
  const selectedFiles = [...localFiles.map((file) => ({
54266
56366
  file,
54267
56367
  sourceRelativeFilePath: relative(rulesyncOutputRoot, file),
@@ -54271,25 +56371,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
54271
56371
  sourceRelativeFilePath: join(".curated", relativeFilePath),
54272
56372
  relativeFilePath
54273
56373
  }))];
54274
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
54275
- const rulesyncRules = await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
56374
+ this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
56375
+ return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
54276
56376
  checkPathTraversal({
54277
56377
  relativePath: sourceRelativeFilePath,
54278
56378
  intendedRootDir: rulesyncOutputRoot
54279
56379
  });
54280
56380
  const rule = await RulesyncRule.fromFile({
54281
- outputRoot: this.inputRoot,
56381
+ outputRoot: treeParent,
56382
+ relativeDirPath: treeRulesDirPath,
54282
56383
  relativeFilePath: sourceRelativeFilePath
54283
56384
  });
54284
56385
  if (sourceRelativeFilePath === relativeFilePath) return rule;
54285
56386
  return new RulesyncRule({
54286
- outputRoot: this.inputRoot,
54287
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
56387
+ outputRoot: treeParent,
56388
+ relativeDirPath: treeRulesDirPath,
54288
56389
  relativeFilePath,
54289
56390
  frontmatter: rule.getFrontmatter(),
54290
56391
  body: rule.getBody()
54291
56392
  });
54292
56393
  }));
56394
+ }
56395
+ /**
56396
+ * Implementation of abstract method from FeatureProcessor
56397
+ * Load and parse rulesync rule files from every configured input root's
56398
+ * `.rulesync/rules/` directory, merging by relative path so that a rule
56399
+ * with the same target path from a later root replaces the earlier root's
56400
+ * copy (case-insensitive, matching the intra-root collision handling).
56401
+ */
56402
+ async loadRulesyncFiles() {
56403
+ const rulesyncRules = mergeByCaseInsensitiveIdentity({
56404
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
56405
+ identity: (rule) => rule.getRelativeFilePath(),
56406
+ artifactName: "rule",
56407
+ logger: this.logger
56408
+ });
54293
56409
  const factory = this.getFactory(this.toolTarget);
54294
56410
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
54295
56411
  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}.`);
@@ -54383,23 +56499,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
54383
56499
  });
54384
56500
  }).filter((rule) => rule.isDeletable());
54385
56501
  };
56502
+ /**
56503
+ * Import counterpart of {@link buildDeletionRulesFromPaths} for the
56504
+ * root-shaped scans (root, legacy roots, and read-only roots), whose
56505
+ * paths all sit at a directory Rulesync resolves from the file itself.
56506
+ */
56507
+ const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
56508
+ const relativeDirPath = resolveRelativeDirPath(filePath);
56509
+ checkPathTraversal({
56510
+ relativePath: relativeDirPath,
56511
+ intendedRootDir: this.outputRoot
56512
+ });
56513
+ return factory.class.fromFile({
56514
+ outputRoot: this.outputRoot,
56515
+ relativeDirPath,
56516
+ relativeFilePath: basename(filePath),
56517
+ global: this.global
56518
+ });
56519
+ }));
56520
+ /**
56521
+ * The tool's own root file, as opposed to whichever root
56522
+ * `rootToolRules` ends up resolving. A legacy root reached through
56523
+ * `alternativeRoots` is deliberately not counted here: it is a
56524
+ * hand-authored file Rulesync never writes, so it has folded nothing in,
56525
+ * and in Junie's resolution order it ranks *below* the multi-file layout
56526
+ * that `importOnlyRoots` describes. Gating those roots on it would drop
56527
+ * exactly the files the tool is really reading.
56528
+ *
56529
+ * Resolved once, up front, so the two blocks that need it do not depend
56530
+ * on each other's evaluation order.
56531
+ */
56532
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
54386
56533
  const rootToolRules = await (async () => {
54387
56534
  if (!settablePaths.root) return [];
54388
- const uniqueRootFilePaths = await findFilesWithFallback(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
56535
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
54389
56536
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
54390
- return await Promise.all(uniqueRootFilePaths.map((filePath) => {
54391
- const relativeDirPath = resolveRelativeDirPath(filePath);
54392
- checkPathTraversal({
54393
- relativePath: relativeDirPath,
54394
- intendedRootDir: this.outputRoot
54395
- });
54396
- return factory.class.fromFile({
54397
- outputRoot: this.outputRoot,
54398
- relativeFilePath: basename(filePath),
54399
- relativeDirPath,
54400
- global: this.global
54401
- });
54402
- }));
56537
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
54403
56538
  })();
54404
56539
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
54405
56540
  const localRootToolRules = await (async () => {
@@ -54411,7 +56546,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54411
56546
  fileName
54412
56547
  }));
54413
56548
  if (!settablePaths.root) return [];
54414
- return await findFilesWithFallback(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
56549
+ return await findFilesWithFallback(await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
54415
56550
  })();
54416
56551
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
54417
56552
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -54487,6 +56622,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
54487
56622
  }));
54488
56623
  })();
54489
56624
  this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
56625
+ const importOnlyToolRules = await (async () => {
56626
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
56627
+ if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
56628
+ const rootFilePath = primaryRootFilePaths[0];
56629
+ const scannedPaths = [];
56630
+ const skippedPaths = [];
56631
+ for (const importOnlyRoot of importOnlyRoots) {
56632
+ const matchedPaths = await findFilesByGlobs(join(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
56633
+ if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
56634
+ skippedPaths.push(...matchedPaths);
56635
+ continue;
56636
+ }
56637
+ scannedPaths.push(...matchedPaths);
56638
+ }
56639
+ if (skippedPaths.length > 0 && rootFilePath !== void 0) {
56640
+ const skippedNames = skippedPaths.map((filePath) => stripControlCharacters(relative(this.outputRoot, filePath)));
56641
+ const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
56642
+ const remainingCount = skippedNames.length - listedNames.length;
56643
+ this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters(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.`);
56644
+ }
56645
+ return await buildImportRulesFromPaths(scannedPaths);
56646
+ })();
56647
+ this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
54490
56648
  const nonRootToolRules = await (async () => {
54491
56649
  if (!settablePaths.nonRoot) return [];
54492
56650
  const nonRootOutputRoot = join(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54519,6 +56677,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54519
56677
  })();
54520
56678
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54521
56679
  return [
56680
+ ...importOnlyToolRules,
54522
56681
  ...rootToolRules,
54523
56682
  ...localRootToolRules,
54524
56683
  ...rootMirrorDeletionRules,
@@ -54632,6 +56791,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
54632
56791
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
54633
56792
  */
54634
56793
  async function convertFromTool(params) {
56794
+ resetWarnedOnceMessages();
54635
56795
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
54636
56796
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
54637
56797
  const ctx = params;
@@ -55326,14 +57486,53 @@ function warnUnsupportedTargets(params) {
55326
57486
  }
55327
57487
  }
55328
57488
  /**
55329
- * Check if .rulesync directory exists.
55330
- *
55331
- * The `.rulesync/` directory lives under the *input* root (where source rules
55332
- * are read from), not under any individual output root, so callers always pass
55333
- * `config.getInputRoot()` here.
57489
+ * Inspect every configured input-root path. The first entry is the required
57490
+ * base source tree; later entries are optional overlays and may be absent.
57491
+ * Each existing entry is a rulesync source tree itself (the directory that
57492
+ * directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
57493
+ * directories are valid because delete and check workflows still need to
57494
+ * inspect generated outputs.
55334
57495
  */
55335
- async function checkRulesyncDirExists(params) {
55336
- return fileExists(join(params.inputRoot, RULESYNC_RELATIVE_DIR_PATH));
57496
+ async function inspectInputRoots(inputRoots) {
57497
+ const existing = [];
57498
+ const missing = [];
57499
+ const invalidOverlays = [];
57500
+ const nonDirectories = /* @__PURE__ */ new Set();
57501
+ for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
57502
+ else {
57503
+ missing.push(root);
57504
+ if (await fileExists(root)) {
57505
+ nonDirectories.add(root);
57506
+ if (index > 0) invalidOverlays.push(root);
57507
+ }
57508
+ }
57509
+ const primaryRoot = inputRoots[0];
57510
+ const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
57511
+ if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
57512
+ const invalidOverlay = invalidOverlays[0];
57513
+ return {
57514
+ existing,
57515
+ missing,
57516
+ message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
57517
+ };
57518
+ }
57519
+ const defaultRoot = join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
57520
+ if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
57521
+ existing,
57522
+ missing,
57523
+ message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
57524
+ };
57525
+ const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
57526
+ if (nonDirectories.has(primaryRoot)) return {
57527
+ existing,
57528
+ missing,
57529
+ message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
57530
+ };
57531
+ return {
57532
+ existing,
57533
+ missing,
57534
+ message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
57535
+ };
55337
57536
  }
55338
57537
  function dependsOnReachable(byId, from, target) {
55339
57538
  const seen = /* @__PURE__ */ new Set();
@@ -55464,7 +57663,7 @@ async function warnSkillSubagentNameCollisions(params) {
55464
57663
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
55465
57664
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
55466
57665
  const subagentsProcessor = new SubagentsProcessor({
55467
- inputRoot: config.getInputRoot(),
57666
+ inputRoots: config.getInputRoots(),
55468
57667
  toolTarget,
55469
57668
  global,
55470
57669
  logger
@@ -55472,7 +57671,7 @@ async function warnSkillSubagentNameCollisions(params) {
55472
57671
  const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => basename(file.getRelativeFilePath(), extname(file.getRelativeFilePath()))));
55473
57672
  if (subagentNames.size === 0) continue;
55474
57673
  const skillNames = (await new SkillsProcessor({
55475
- inputRoot: config.getInputRoot(),
57674
+ inputRoots: config.getInputRoots(),
55476
57675
  toolTarget,
55477
57676
  global,
55478
57677
  logger
@@ -55521,6 +57720,8 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55521
57720
  */
55522
57721
  async function generate(params) {
55523
57722
  const { config, logger } = params;
57723
+ resetRootShadowingWarnings({ logger });
57724
+ resetWarnedOnceMessages();
55524
57725
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55525
57726
  toolTarget,
55526
57727
  outputRoot
@@ -55659,7 +57860,7 @@ async function generateRulesCore(params) {
55659
57860
  toolTarget,
55660
57861
  global: config.getGlobal()
55661
57862
  }),
55662
- inputRoot: config.getInputRoot(),
57863
+ inputRoots: config.getInputRoots(),
55663
57864
  toolTarget,
55664
57865
  global: config.getGlobal(),
55665
57866
  simulateCommands: config.getSimulateCommands(),
@@ -55709,7 +57910,7 @@ async function generateIgnoreCore(params) {
55709
57910
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55710
57911
  const processor = new IgnoreProcessor({
55711
57912
  outputRoot,
55712
- inputRoot: config.getInputRoot(),
57913
+ inputRoots: config.getInputRoots(),
55713
57914
  toolTarget,
55714
57915
  global,
55715
57916
  dryRun: config.isPreviewMode(),
@@ -55756,7 +57957,7 @@ async function generateMcpCore(params) {
55756
57957
  toolTarget,
55757
57958
  global: config.getGlobal()
55758
57959
  }),
55759
- inputRoot: config.getInputRoot(),
57960
+ inputRoots: config.getInputRoots(),
55760
57961
  toolTarget,
55761
57962
  global: config.getGlobal(),
55762
57963
  dryRun: config.isPreviewMode(),
@@ -55802,7 +58003,7 @@ async function generateCommandsCore(params) {
55802
58003
  toolTarget,
55803
58004
  global: config.getGlobal()
55804
58005
  }),
55805
- inputRoot: config.getInputRoot(),
58006
+ inputRoots: config.getInputRoots(),
55806
58007
  toolTarget,
55807
58008
  global: config.getGlobal(),
55808
58009
  dryRun: config.isPreviewMode(),
@@ -55849,7 +58050,7 @@ async function generateSubagentsCore(params) {
55849
58050
  toolTarget,
55850
58051
  global: config.getGlobal()
55851
58052
  }),
55852
- inputRoot: config.getInputRoot(),
58053
+ inputRoots: config.getInputRoots(),
55853
58054
  toolTarget,
55854
58055
  global: config.getGlobal(),
55855
58056
  dryRun: config.isPreviewMode(),
@@ -55896,7 +58097,7 @@ async function generateSkillsCore(params) {
55896
58097
  toolTarget,
55897
58098
  global: config.getGlobal()
55898
58099
  }),
55899
- inputRoot: config.getInputRoot(),
58100
+ inputRoots: config.getInputRoots(),
55900
58101
  toolTarget,
55901
58102
  global: config.getGlobal(),
55902
58103
  dryRun: config.isPreviewMode(),
@@ -55941,7 +58142,7 @@ async function generateHooksCore(params) {
55941
58142
  toolTarget,
55942
58143
  global: config.getGlobal()
55943
58144
  }),
55944
- inputRoot: config.getInputRoot(),
58145
+ inputRoots: config.getInputRoots(),
55945
58146
  toolTarget,
55946
58147
  global: config.getGlobal(),
55947
58148
  dryRun: config.isPreviewMode(),
@@ -55983,7 +58184,7 @@ async function generatePermissionsCore(params) {
55983
58184
  toolTarget,
55984
58185
  global: config.getGlobal()
55985
58186
  }),
55986
- inputRoot: config.getInputRoot(),
58187
+ inputRoots: config.getInputRoots(),
55987
58188
  toolTarget,
55988
58189
  global: config.getGlobal(),
55989
58190
  dryRun: config.isPreviewMode(),
@@ -56029,7 +58230,7 @@ async function generateChecksCore(params) {
56029
58230
  toolTarget,
56030
58231
  global: config.getGlobal()
56031
58232
  }),
56032
- inputRoot: config.getInputRoot(),
58233
+ inputRoots: config.getInputRoots(),
56033
58234
  toolTarget,
56034
58235
  global: config.getGlobal(),
56035
58236
  dryRun: config.isPreviewMode(),
@@ -56080,6 +58281,7 @@ function getToolOutputRoot({ config, tool }) {
56080
58281
  */
56081
58282
  async function importFromTool(params) {
56082
58283
  const { config, tool, logger } = params;
58284
+ resetWarnedOnceMessages();
56083
58285
  await assertPluginRootSafe({
56084
58286
  toolTarget: tool,
56085
58287
  outputRoot: getToolOutputRoot({
@@ -56401,6 +58603,6 @@ async function importChecksCore(params) {
56401
58603
  return writtenCount;
56402
58604
  }
56403
58605
  //#endregion
56404
- export { ConsoleLogger as $, RULESYNC_PERMISSIONS_FILE_NAME as $t, RulesyncRule as A, ALL_TOOL_TARGETS as At, RulesyncCommandFrontmatterSchema as B, RULESYNC_CONFIG_SCHEMA_URL as Bt, CODEXCLI_BASH_RULES_FILE_NAME as C, removeFileStrict as Ct, RulesyncSubagentFrontmatterSchema as D, toPosixPath as Dt, RulesyncSubagent as E, runWithDirectoryRollback as Et, RulesyncHooks as F, RULESYNC_AIIGNORE_FILE_NAME as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Gt, RulesyncCheckFrontmatterSchema as H, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Ht, getRulesyncSourceCandidates as I, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as It, CONFLICTING_TARGET_PAIRS as J, RULESYNC_MCP_FILE_NAME as Jt, SKILL_FILE_NAME as K, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Kt, resolveRulesyncSourceWritePath as L, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Lt, RulesyncPermissions as M, PACKAGING_TOOL_TARGETS as Mt, RulesyncMcp as N, ToolTargetSchema as Nt, RulesyncSkill as O, writeFileBuffer as Ot, RulesyncIgnore as P, MAX_FILE_SIZE as Pt, findControlCharacter as Q, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Qt, parseJsonc as R, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Rt, ChecksProcessor as S, removeFile as St, getLocalSkillDirNames as T, resolvePath as Tt, stringifyFrontmatter as U, RULESYNC_HOOKS_FILE_NAME as Ut, RulesyncCheck as V, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Vt, loadYaml as W, RULESYNC_HOOKS_LEGACY_FILE_NAME as Wt, GITIGNORE_DESTINATION_KEY as X, RULESYNC_MCP_RELATIVE_FILE_PATH as Xt, ConfigFileSchema as Y, RULESYNC_MCP_LEGACY_FILE_NAME as Yt, SourceEntrySchema as Z, RULESYNC_MCP_SCHEMA_URL as Zt, CLAUDECODE_DIR as _, listDirectoryFiles as _t, convertFromTool as a, RULESYNC_SKILLS_RELATIVE_DIR_PATH as an, assertDirectoryIfExists as at, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as b, removeDirectory as bt, SubagentsProcessor as c, ALL_FEATURES as cn, checkPathTraversal as ct, McpProcessor as d, formatError as dn, ensureDir as dt, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as en, JsonLogger as et, IgnoreProcessor as f, fileExists as ft, QWENCODE_LOCAL_RULE_FILE_NAME as g, isSymlink as gt, QWENCODE_DIR as h, getHomeDirectory as ht, getProcessorRegistryEntry as i, RULESYNC_RULES_RELATIVE_DIR_PATH as in, ErrorCodes as it, RulesyncRuleFrontmatterSchema as j, ALL_TOOL_TARGETS_WITH_WILDCARD as jt, RulesyncSkillFrontmatterSchema as k, writeFileContent as kt, SkillsProcessor as l, ALL_FEATURES_WITH_WILDCARD as ln, createTempDirectory as lt, CommandsProcessor as m, getFileSize as mt, checkRulesyncDirExists as n, RULESYNC_PERMISSIONS_SCHEMA_URL as nn, warnOnConflictingFlags as nt, isPackagingToolTarget as o, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as on, assertTreeContainsNoSymlinks as ot, HooksProcessor as p, findFilesByGlobs as pt, ConfigResolver as q, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as qt, generate as r, RULESYNC_RELATIVE_DIR_PATH as rn, CLIError as rt, RulesProcessor as s, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as sn, assertWritablePathInsideRoot as st, importFromTool as t, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as tn, fallbackLogger as tt, stripControlCharacters as u, DEPRECATED_FEATURE_REPLACEMENTS as un, directoryExists as ut, CLAUDECODE_LOCAL_RULE_FILE_NAME as v, readFileContent as vt, CODEXCLI_DIR as w, removeTempDirectory as wt, CLAUDECODE_SKILLS_DIR_PATH as x, removeDirectoryStrict as xt, CLAUDECODE_MEMORIES_DIR_NAME as y, readFileContentOrNull as yt, RulesyncCommand as z, RULESYNC_CONFIG_RELATIVE_FILE_PATH as zt };
58606
+ export { SourceEntrySchema as $, RULESYNC_MCP_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, runWithDirectoryRollback as At, RulesyncCheck as B, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Bt, CODEXCLI_DIR as C, readFileContentOrNull as Ct, RulesyncSkill as D, removeFileStrict as Dt, RulesyncSubagentFrontmatterSchema as E, removeFile as Et, getRulesyncSourceCandidates as F, ALL_TOOL_TARGETS_WITH_WILDCARD as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Gt, stringifyFrontmatter as H, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Ht, resolveRulesyncSourceWritePath as I, PACKAGING_TOOL_TARGETS as It, mergeInputRootConfigs as J, RULESYNC_HOOKS_LEGACY_FILE_NAME as Jt, SKILL_FILE_NAME as K, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Kt, parseJsonc as L, ToolTargetSchema as Lt, RulesyncMcp as M, writeFileBuffer as Mt, RulesyncIgnore as N, writeFileContent as Nt, RulesyncSkillFrontmatterSchema as O, removeTempDirectory as Ot, RulesyncHooks as P, ALL_TOOL_TARGETS as Pt, GITIGNORE_DESTINATION_KEY as Q, RULESYNC_MCP_FILE_NAME as Qt, RulesyncCommand as R, MAX_FILE_SIZE as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, readFileContent as St, RulesyncSubagent as T, removeDirectoryStrict as Tt, loadYaml as U, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Vt, stripControlCharacters as W, RULESYNC_CONFIG_SCHEMA_URL as Wt, CONFLICTING_TARGET_PAIRS as X, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Xt, resolveEffectiveInputRoots as Y, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Yt, ConfigFileSchema as Z, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, getFileSize as _t, convertFromTool as a, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as an, resetWarnedOnceMessages as at, CLAUDECODE_SKILLS_DIR_PATH as b, listDirectoryFiles as bt, SubagentsProcessor as c, RULESYNC_RULES_RELATIVE_DIR_PATH as cn, assertDirectoryIfExists as ct, IgnoreProcessor as d, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as dn, checkPathTraversal as dt, RULESYNC_MCP_RELATIVE_FILE_PATH as en, findControlCharacter as et, HooksProcessor as f, ALL_FEATURES as fn, createTempDirectory as ft, CLAUDECODE_DIR as g, findFilesByGlobs as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, formatError as hn, fileExists as ht, getProcessorRegistryEntry as i, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as in, warnOnConflictingFlags as it, RulesyncPermissions as j, toPosixPath as jt, RulesyncRule as k, resolvePath as kt, SkillsProcessor as l, RULESYNC_SKILLS_RELATIVE_DIR_PATH as ln, assertTreeContainsNoSymlinks as lt, QWENCODE_DIR as m, DEPRECATED_FEATURE_REPLACEMENTS as mn, ensureDir as mt, generate as n, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as nn, JsonLogger as nt, isPackagingToolTarget as o, RULESYNC_PERMISSIONS_SCHEMA_URL as on, CLIError as ot, CommandsProcessor as p, ALL_FEATURES_WITH_WILDCARD as pn, directoryExists as pt, ConfigResolver as q, RULESYNC_HOOKS_FILE_NAME as qt, inspectInputRoots as r, RULESYNC_PERMISSIONS_FILE_NAME as rn, fallbackLogger as rt, RulesProcessor as s, RULESYNC_RELATIVE_DIR_PATH as sn, ErrorCodes as st, importFromTool as t, RULESYNC_MCP_SCHEMA_URL as tn, ConsoleLogger as tt, McpProcessor as u, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as un, assertWritablePathInsideRoot as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, getHomeDirectory as vt, getLocalSkillDirNames as w, removeDirectory as wt, ChecksProcessor as x, pathEscapesRoot as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, isSymlink as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_AIIGNORE_FILE_NAME as zt };
56405
58607
 
56406
- //# sourceMappingURL=import-BPTCMtUS.js.map
58608
+ //# sourceMappingURL=import-u8yswsGB.js.map