rulesync 16.15.0 → 16.16.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.
@@ -113,11 +113,18 @@ const { join: join$1 } = posix;
113
113
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
114
114
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
115
115
  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");
116
+ const RULES_FEATURE_SUBDIR = "rules";
117
+ const CURATED_RULES_FEATURE_SUBDIR = join$1(RULES_FEATURE_SUBDIR, ".curated");
118
+ const COMMANDS_FEATURE_SUBDIR = "commands";
119
+ const SUBAGENTS_FEATURE_SUBDIR = "subagents";
120
+ const CHECKS_FEATURE_SUBDIR = "checks";
121
+ const SKILLS_FEATURE_SUBDIR = "skills";
122
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$1(SKILLS_FEATURE_SUBDIR, ".curated");
123
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
124
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
125
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
126
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
127
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
121
128
  const RULESYNC_MCP_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
122
129
  const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
123
130
  const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
@@ -128,8 +135,8 @@ const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
128
135
  const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
129
136
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
130
137
  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");
138
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
139
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$1(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
133
140
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
134
141
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
135
142
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -1117,6 +1124,7 @@ const ConfigParamsSchema = z.object({
1117
1124
  dryRun: optional(z.boolean()),
1118
1125
  check: optional(z.boolean()),
1119
1126
  inputRoot: optional(z.string()),
1127
+ inputRoots: optional(z.array(z.string()).check(minLength(1, "inputRoots must be non-empty"))),
1120
1128
  sources: optional(z.array(SourceEntrySchema))
1121
1129
  });
1122
1130
  z.partial(ConfigParamsSchema);
@@ -1129,14 +1137,39 @@ z.required(ConfigParamsSchema);
1129
1137
  * Normalizes the configuration file location to an absolute path.
1130
1138
  *
1131
1139
  * `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.
1140
+ * only covers direct programmatic construction. `anchorDir` is the directory
1141
+ * the config file lives next to for the default `.rulesync/` layout this
1142
+ * is the parent of the primary source tree.
1134
1143
  */
1135
- function normalizeConfigFilePath({ configFilePath, inputRoot }) {
1136
- if (configFilePath === void 0) return join(inputRoot, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1144
+ function normalizeConfigFilePath({ configFilePath, anchorDir }) {
1145
+ if (configFilePath === void 0) return join(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1137
1146
  return isAbsolute(configFilePath) ? configFilePath : resolve(configFilePath);
1138
1147
  }
1139
1148
  /**
1149
+ * Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
1150
+ * neither) to the canonical non-empty tuple of absolute paths that
1151
+ * `Config` stores. Relative entries are resolved against the current
1152
+ * working directory at call time.
1153
+ *
1154
+ * Semantics (post-refactor):
1155
+ * - Each entry in `inputRoots` is a rulesync **source tree** (the directory
1156
+ * that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
1157
+ * `.rulesync/` join is applied.
1158
+ * - The legacy singular `inputRoot` is a shorthand for "parent of the
1159
+ * default `.rulesync/` source tree", and is expanded to
1160
+ * `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
1161
+ * the ONLY place `.rulesync` is appended by convention.
1162
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1163
+ * so existing projects with a single `.rulesync/` tree keep working
1164
+ * unchanged.
1165
+ *
1166
+ * Callers must have already run `assertInputRootFieldsExclusive`.
1167
+ */
1168
+ function normalizeInputRoots({ inputRoot, inputRoots }) {
1169
+ 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));
1170
+ return [resolved[0], ...resolved.slice(1)];
1171
+ }
1172
+ /**
1140
1173
  * Conflicting target pairs that cannot be used together.
1141
1174
  * Exported so `rulesync doctor` can report the same conflicts as diagnostics
1142
1175
  * without duplicating the list.
@@ -1173,6 +1206,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
1173
1206
  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
1207
  };
1175
1208
  /**
1209
+ * Rejects a single user-authored config file (or a single programmatic
1210
+ * construction) that defines both `inputRoot` and `inputRoots` — the two
1211
+ * fields express the same setting at singular vs. list level and cannot
1212
+ * be combined within one file without ambiguity.
1213
+ *
1214
+ * The check is intentionally per-file: base and local config files can each
1215
+ * be valid in isolation and merge into a state where both survive, and the
1216
+ * resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
1217
+ * Only a single file declaring both is a genuine authoring error.
1218
+ */
1219
+ const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
1220
+ 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.");
1221
+ };
1222
+ /**
1223
+ * Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
1224
+ * selects the conventional default, while an empty list has no meaningful
1225
+ * source-tree semantics and must not be treated as an absent override.
1226
+ */
1227
+ const assertInputRootsNonEmpty = ({ inputRoots }) => {
1228
+ if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
1229
+ };
1230
+ /**
1176
1231
  * Normalizes a post-resolution `ConfigParams` input by rejecting the case
1177
1232
  * where both `targets` and `features` are undefined — a degenerate state
1178
1233
  * that would silently produce a no-op config (no targets, no features).
@@ -1208,14 +1263,35 @@ var Config = class Config {
1208
1263
  gitignoreDestination;
1209
1264
  dryRun;
1210
1265
  check;
1211
- inputRoot;
1266
+ /**
1267
+ * Ordered, absolute-path list of rulesync source trees. Each entry is a
1268
+ * source tree itself — the directory that directly contains `rules/`,
1269
+ * `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
1270
+ *
1271
+ * Always non-empty by construction — the constructor either normalizes
1272
+ * an `inputRoot`/`inputRoots` input or falls back to a single-element
1273
+ * list containing `join(<cwd>, ".rulesync")`.
1274
+ *
1275
+ * `inputRoot` (singular) is a deprecated backward-compatibility alias
1276
+ * that expands to `[join(inputRoot, ".rulesync")]`.
1277
+ *
1278
+ * Typed as a non-empty tuple so the one internal caller that legitimately
1279
+ * needs "the primary root" (`normalizeConfigFilePath` fallback) can index
1280
+ * `[0]` without a runtime null-check.
1281
+ */
1282
+ inputRoots;
1212
1283
  configFilePath;
1213
1284
  sources;
1214
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
1285
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
1215
1286
  assertTargetsFeaturesExclusive({
1216
1287
  targets,
1217
1288
  features
1218
1289
  });
1290
+ assertInputRootFieldsExclusive({
1291
+ inputRoot,
1292
+ inputRoots
1293
+ });
1294
+ assertInputRootsNonEmpty({ inputRoots });
1219
1295
  assertTargetsOrFeaturesProvided({
1220
1296
  targets,
1221
1297
  features
@@ -1243,10 +1319,13 @@ var Config = class Config {
1243
1319
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
1244
1320
  this.dryRun = dryRun ?? false;
1245
1321
  this.check = check ?? false;
1246
- this.inputRoot = inputRoot === void 0 ? process.cwd() : isAbsolute(inputRoot) ? inputRoot : resolve(inputRoot);
1322
+ this.inputRoots = normalizeInputRoots({
1323
+ inputRoot,
1324
+ inputRoots
1325
+ });
1247
1326
  this.configFilePath = normalizeConfigFilePath({
1248
1327
  configFilePath,
1249
- inputRoot: this.inputRoot
1328
+ anchorDir: dirname(this.inputRoots[0])
1250
1329
  });
1251
1330
  this.sources = sources ?? [];
1252
1331
  }
@@ -1430,16 +1509,22 @@ var Config = class Config {
1430
1509
  return this.check;
1431
1510
  }
1432
1511
  /**
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.
1512
+ * Returns the ordered list of rulesync source trees. Each entry is the
1513
+ * source tree itself the directory that directly contains `rules/`,
1514
+ * `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
1515
+ * config-construction time, so this accessor is pure and never depends on
1516
+ * a live `process.cwd()` read.
1436
1517
  *
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.
1518
+ * The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
1519
+ * was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
1520
+ * during construction. The first entry is the required base source tree.
1521
+ * Later entries are optional overlays and may be absent; when present, they
1522
+ * take precedence when the same relative source path exists in more than
1523
+ * one root (see per-feature merge policies in the processor
1524
+ * `loadRulesync*` methods).
1440
1525
  */
1441
- getInputRoot() {
1442
- return this.inputRoot;
1526
+ getInputRoots() {
1527
+ return this.inputRoots;
1443
1528
  }
1444
1529
  /**
1445
1530
  * Returns the absolute path of the configuration file this config was
@@ -1480,6 +1565,7 @@ const getDefaults = () => ({
1480
1565
  dryRun: false,
1481
1566
  check: false,
1482
1567
  inputRoot: void 0,
1568
+ inputRoots: void 0,
1483
1569
  sources: []
1484
1570
  });
1485
1571
  const loadConfigFromFile = async (filePath) => {
@@ -1491,8 +1577,23 @@ const loadConfigFromFile = async (filePath) => {
1491
1577
  targets: configParams.targets,
1492
1578
  features: configParams.features
1493
1579
  });
1580
+ try {
1581
+ assertInputRootFieldsExclusive({
1582
+ inputRoot: configParams.inputRoot,
1583
+ inputRoots: configParams.inputRoots
1584
+ });
1585
+ } catch (error) {
1586
+ const detail = error instanceof Error ? error.message : String(error);
1587
+ throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
1588
+ }
1494
1589
  return configParams;
1495
1590
  };
1591
+ function mergeInputRootConfigs({ baseConfig, localConfig }) {
1592
+ return {
1593
+ inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1594
+ inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
1595
+ };
1596
+ }
1496
1597
  const mergeConfigs = (baseConfig, localConfig) => {
1497
1598
  return {
1498
1599
  targets: localConfig.targets ?? baseConfig.targets,
@@ -1510,7 +1611,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
1510
1611
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
1511
1612
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
1512
1613
  check: localConfig.check ?? baseConfig.check,
1513
- inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1614
+ ...mergeInputRootConfigs({
1615
+ baseConfig,
1616
+ localConfig
1617
+ }),
1514
1618
  sources: localConfig.sources ?? baseConfig.sources
1515
1619
  };
1516
1620
  };
@@ -1539,13 +1643,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
1539
1643
  }
1540
1644
  }
1541
1645
  /**
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
1646
+ * Resolve the effective `global` flag. When an input root (singular
1647
+ * `inputRoot` or plural `inputRoots`) is in play the user is decoupling
1648
+ * source from output, so a config-file `global: true` is dropped (unless
1649
+ * the caller also explicitly passes `global`); a warning is emitted in
1545
1650
  * that case. Returns the resolved boolean `global`.
1546
1651
  */
1547
1652
  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).`);
1653
+ 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
1654
  return pick({
1550
1655
  cli: global,
1551
1656
  file: resolvedInputRoot !== void 0 ? false : configByFile.global,
@@ -1569,17 +1674,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
1569
1674
  resolvedTargets: userProvidedTargets ?? getDefaults().targets
1570
1675
  };
1571
1676
  }
1677
+ /**
1678
+ * Resolve the effective, non-empty, absolute-path list of source-tree roots
1679
+ * by applying CLI > file > default precedence and preferring `inputRoots`
1680
+ * over `inputRoot` when both survive the base+local merge. Duplicates (after
1681
+ * normalization to absolute paths) are removed silently so overlapping
1682
+ * base/local declarations do not double-count the same tree.
1683
+ *
1684
+ * Semantics (post-refactor):
1685
+ * - `inputRoots` entries are the source trees themselves (each holds
1686
+ * `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
1687
+ * unchanged.
1688
+ * - `inputRoot` (legacy singular) is a shorthand for "parent of the
1689
+ * `.rulesync/` source tree" and is expanded to `join(inputRoot,
1690
+ * ".rulesync")` before it hits any consumer.
1691
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1692
+ * so existing projects keep working unchanged.
1693
+ *
1694
+ * When both the merged file config has `inputRoots` and the CLI supplied
1695
+ * `inputRoot` (or vice versa), CLI wins outright — matching how every
1696
+ * other field is resolved. When only the file config supplies both, the
1697
+ * plural wins over the singular and the drop is logged at debug level.
1698
+ */
1699
+ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
1700
+ let source;
1701
+ let field;
1702
+ if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
1703
+ source = cliInputRoots;
1704
+ field = "inputRoots";
1705
+ } else if (cliInputRoot !== void 0) {
1706
+ source = [join(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1707
+ field = "inputRoot";
1708
+ } else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
1709
+ source = configByFile.inputRoots;
1710
+ field = "inputRoots";
1711
+ 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.`);
1712
+ } else if (configByFile.inputRoot !== void 0) {
1713
+ source = [join(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1714
+ field = "inputRoot";
1715
+ } else source = [join(cwd, RULESYNC_RELATIVE_DIR_PATH)];
1716
+ const candidates = source.map((entry) => resolve(cwd, entry));
1717
+ const seen = /* @__PURE__ */ new Set();
1718
+ const resolved = [];
1719
+ for (const absolute of candidates) {
1720
+ if (seen.has(absolute)) continue;
1721
+ seen.add(absolute);
1722
+ resolved.push(absolute);
1723
+ }
1724
+ return {
1725
+ inputRoots: [resolved[0], ...resolved.slice(1)],
1726
+ candidates,
1727
+ field
1728
+ };
1729
+ }
1572
1730
  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 } = {}) {
1731
+ 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
1732
  const cwd = resolve(process.cwd());
1733
+ assertInputRootFieldsExclusive({
1734
+ inputRoot,
1735
+ inputRoots
1736
+ });
1737
+ assertInputRootsNonEmpty({ inputRoots });
1575
1738
  if (inputRoot !== void 0) validateOutputRoot(inputRoot);
1576
- const validatedConfigPath = resolvePath(configPath, resolve(inputRoot ?? cwd));
1739
+ if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
1740
+ const cliConfigAnchor = inputRoot;
1741
+ const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
1742
+ const validatedConfigPath = resolvePath(configPath, resolve(cliConfigAnchor ?? cwd));
1577
1743
  const baseConfig = await loadConfigFromFile(validatedConfigPath);
1578
1744
  const configDir = dirname(validatedConfigPath);
1579
1745
  const localConfigPath = join(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
1580
1746
  const localConfig = await loadConfigFromFile(localConfigPath);
1581
1747
  const configByFile = mergeConfigs(baseConfig, localConfig);
1582
- if (inputRoot === void 0 && configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1748
+ if (!hasCliInputRootOverride) {
1749
+ if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1750
+ if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
1751
+ }
1583
1752
  assertMergedTargetsFeaturesExclusive({
1584
1753
  configByFile,
1585
1754
  validatedConfigPath,
@@ -1605,10 +1774,16 @@ var ConfigResolver = class {
1605
1774
  silent: resolvedSilent
1606
1775
  });
1607
1776
  }
1608
- const resolvedInputRoot = inputRoot ?? configByFile.inputRoot;
1777
+ const resolvedInputRoots = resolveEffectiveInputRoots({
1778
+ cliInputRoot: inputRoot,
1779
+ cliInputRoots: inputRoots,
1780
+ configByFile,
1781
+ cwd,
1782
+ logger
1783
+ }).inputRoots;
1609
1784
  const resolvedGlobal = resolveGlobal({
1610
1785
  logger,
1611
- resolvedInputRoot,
1786
+ resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
1612
1787
  global,
1613
1788
  configByFile,
1614
1789
  validatedConfigPath
@@ -1672,7 +1847,7 @@ var ConfigResolver = class {
1672
1847
  file: configByFile.check,
1673
1848
  fallback: getDefaults().check
1674
1849
  }),
1675
- inputRoot: resolvedInputRoot !== void 0 ? resolve(resolvedInputRoot) : cwd,
1850
+ inputRoots: resolvedInputRoots,
1676
1851
  configFilePath: validatedConfigPath,
1677
1852
  sources: configByFile.sources ?? getDefaults().sources,
1678
1853
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
@@ -2072,8 +2247,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2072
2247
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2073
2248
  };
2074
2249
  }
2075
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2076
- const filePath = join(outputRoot, RULESYNC_CHECKS_RELATIVE_DIR_PATH, relativeFilePath);
2250
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2251
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
2252
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
2077
2253
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
2078
2254
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
2079
2255
  const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
@@ -2081,7 +2257,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2081
2257
  const filename = basename(relativeFilePath);
2082
2258
  return new RulesyncCheck({
2083
2259
  outputRoot,
2084
- relativeDirPath: this.getSettablePaths().relativeDirPath,
2260
+ relativeDirPath: dirPath,
2085
2261
  relativeFilePath: filename,
2086
2262
  frontmatter: result.data,
2087
2263
  body: content.trim()
@@ -2152,8 +2328,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2152
2328
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2153
2329
  };
2154
2330
  }
2155
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2156
- const filePath = join(outputRoot, RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath);
2331
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2332
+ const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
2333
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
2157
2334
  const fileContent = await readFileContent(filePath);
2158
2335
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
2159
2336
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
@@ -2161,7 +2338,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2161
2338
  if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
2162
2339
  return new RulesyncCommand({
2163
2340
  outputRoot,
2164
- relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
2341
+ relativeDirPath: dirPath,
2165
2342
  relativeFilePath,
2166
2343
  frontmatter: result.data,
2167
2344
  body: content.trim(),
@@ -3550,8 +3727,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
3550
3727
  };
3551
3728
  /**
3552
3729
  * Map Mistral Vibe snake_case event names to canonical camelCase.
3730
+ *
3731
+ * The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
3732
+ * strict `HookType` enum rejects a file that still uses them, so such a file is
3733
+ * already dead on disk; reading it here and emitting the renamed spelling is
3734
+ * what repairs it, whereas leaving the old name unmapped would route the hook
3735
+ * into a tool override block and lose the event.
3553
3736
  */
3554
- const VIBE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k]));
3737
+ const VIBE_TO_CANONICAL_EVENT_NAMES = {
3738
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
3739
+ before_tool: "preToolUse",
3740
+ after_tool: "postToolUse",
3741
+ post_agent_turn: "stop"
3742
+ };
3555
3743
  /**
3556
3744
  * Map canonical camelCase event names to Qwen Code PascalCase.
3557
3745
  *
@@ -3772,15 +3960,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
3772
3960
  error: null
3773
3961
  };
3774
3962
  }
3775
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
3963
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
3776
3964
  const paths = RulesyncHooks.getSettablePaths();
3965
+ const overrideDirPath = relativeDirPath;
3777
3966
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
3778
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
3967
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
3968
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
3779
3969
  if (!await fileExists(filePath)) continue;
3780
3970
  const fileContent = await readFileContent(filePath);
3781
3971
  return new RulesyncHooks({
3782
3972
  outputRoot,
3783
- relativeDirPath: candidate.relativeDirPath,
3973
+ relativeDirPath: candidateDirPath,
3784
3974
  relativeFilePath: candidate.relativeFilePath,
3785
3975
  fileContent,
3786
3976
  validate
@@ -3807,21 +3997,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3807
3997
  relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
3808
3998
  relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
3809
3999
  },
3810
- legacy: {
4000
+ legacy: [{
3811
4001
  relativeDirPath: ".",
3812
4002
  relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
3813
- }
4003
+ }]
3814
4004
  };
3815
4005
  }
3816
- static async fromFile({ outputRoot = process.cwd() } = {}) {
4006
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
3817
4007
  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);
4008
+ const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
4009
+ const recommendedPath = join(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
4010
+ const [legacy] = paths.legacy;
4011
+ const legacyPath = join(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
3820
4012
  if (await fileExists(recommendedPath)) {
3821
4013
  const fileContent = await readFileContent(recommendedPath);
3822
4014
  return new RulesyncIgnore({
3823
4015
  outputRoot,
3824
- relativeDirPath: paths.recommended.relativeDirPath,
4016
+ relativeDirPath: recommendedDirPath,
3825
4017
  relativeFilePath: paths.recommended.relativeFilePath,
3826
4018
  fileContent
3827
4019
  });
@@ -3830,15 +4022,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3830
4022
  const fileContent = await readFileContent(legacyPath);
3831
4023
  return new RulesyncIgnore({
3832
4024
  outputRoot,
3833
- relativeDirPath: paths.legacy.relativeDirPath,
3834
- relativeFilePath: paths.legacy.relativeFilePath,
4025
+ relativeDirPath: legacy.relativeDirPath,
4026
+ relativeFilePath: legacy.relativeFilePath,
3835
4027
  fileContent
3836
4028
  });
3837
4029
  }
3838
4030
  const fileContent = await readFileContent(recommendedPath);
3839
4031
  return new RulesyncIgnore({
3840
4032
  outputRoot,
3841
- relativeDirPath: paths.recommended.relativeDirPath,
4033
+ relativeDirPath: recommendedDirPath,
3842
4034
  relativeFilePath: paths.recommended.relativeFilePath,
3843
4035
  fileContent
3844
4036
  });
@@ -3994,6 +4186,118 @@ const RulesyncMcpFileSchema = z.looseObject({
3994
4186
  warp: z.optional(toolScopedMcpSchema),
3995
4187
  zed: z.optional(toolScopedMcpSchema)
3996
4188
  });
4189
+ /**
4190
+ * The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
4191
+ * Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
4192
+ * from the schema — every tool-scoped block declared above is treated as a
4193
+ * "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
4194
+ * (including `$schema` and top-level Kimi Code timeout fields) is replaced
4195
+ * atomically.
4196
+ */
4197
+ const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
4198
+ /**
4199
+ * Return the first candidate path (recommended, then legacy variants) that
4200
+ * exists under `outputRoot`, or `undefined` when none is present. Shared
4201
+ * between `fromFile` (single-root) and `fromRoots` (multi-root) so both
4202
+ * paths honour the same intra-root resolution order.
4203
+ *
4204
+ * When `overrideDirPath` is provided it replaces the candidates'
4205
+ * class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
4206
+ * caller can point at a non-default source tree (e.g. `.rulesync.local/`).
4207
+ * Also returned is the effective `relativeDirPath` for the winning
4208
+ * candidate so the caller can reconstruct a `RulesyncMcp` with matching
4209
+ * anchor fields.
4210
+ */
4211
+ async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
4212
+ for (const candidate of getRulesyncSourceCandidates({ paths })) {
4213
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4214
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
4215
+ if (await fileExists(filePath)) return {
4216
+ filePath,
4217
+ candidate: {
4218
+ relativeDirPath: candidateDirPath,
4219
+ relativeFilePath: candidate.relativeFilePath
4220
+ }
4221
+ };
4222
+ }
4223
+ }
4224
+ /**
4225
+ * Merge two parsed MCP JSON objects with the one-level policy from the
4226
+ * inputRoots plan: the top-level `mcpServers` map and each
4227
+ * `<toolname>.mcpServers` sub-map are merged by server name (later wins per
4228
+ * key). Every other value — individual server configs, other top-level keys
4229
+ * — is replaced atomically. This keeps the merge predictable: an overlay can
4230
+ * add or replace whole shared servers, but a partial patch of one server's
4231
+ * `args`/`env` is deliberately not supported.
4232
+ */
4233
+ function getRecordField({ value, path }) {
4234
+ if (value === void 0) return {};
4235
+ if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
4236
+ return value;
4237
+ }
4238
+ /**
4239
+ * Overlay one record onto another, dropping any overlay key that could reach
4240
+ * `Object.prototype`. Every overlay merge goes through this so a `__proto__`
4241
+ * entry cannot enter the merged config from any depth — top-level
4242
+ * `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
4243
+ */
4244
+ function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
4245
+ const merged = { ...base };
4246
+ for (const [key, value] of Object.entries(overlay)) {
4247
+ if (isPrototypePollutionKey(key)) continue;
4248
+ merged[key] = value;
4249
+ }
4250
+ return merged;
4251
+ }
4252
+ function mergeMcpJsonOverlays({ base, overlay }) {
4253
+ const merged = { ...base };
4254
+ for (const [key, overlayValue] of Object.entries(overlay)) {
4255
+ if (isPrototypePollutionKey(key)) continue;
4256
+ if (key === "mcpServers") {
4257
+ merged.mcpServers = mergeRecordsSkippingPollutionKeys({
4258
+ base: getRecordField({
4259
+ value: base.mcpServers,
4260
+ path: "mcpServers"
4261
+ }),
4262
+ overlay: getRecordField({
4263
+ value: overlayValue,
4264
+ path: "mcpServers"
4265
+ })
4266
+ });
4267
+ continue;
4268
+ }
4269
+ if (TOOL_SCOPED_MCP_KEYS.has(key)) {
4270
+ const baseBlock = getRecordField({
4271
+ value: base[key],
4272
+ path: key
4273
+ });
4274
+ const overlayBlock = getRecordField({
4275
+ value: overlayValue,
4276
+ path: key
4277
+ });
4278
+ const mergedBlock = mergeRecordsSkippingPollutionKeys({
4279
+ base: baseBlock,
4280
+ overlay: overlayBlock
4281
+ });
4282
+ const baseServers = getRecordField({
4283
+ value: baseBlock.mcpServers,
4284
+ path: `${key}.mcpServers`
4285
+ });
4286
+ const overlayServers = getRecordField({
4287
+ value: overlayBlock.mcpServers,
4288
+ path: `${key}.mcpServers`
4289
+ });
4290
+ if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
4291
+ base: baseServers,
4292
+ overlay: overlayServers
4293
+ });
4294
+ merged[key] = mergedBlock;
4295
+ continue;
4296
+ }
4297
+ merged[key] = overlayValue;
4298
+ }
4299
+ return merged;
4300
+ }
3997
4301
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3998
4302
  json;
3999
4303
  constructor(params) {
@@ -4030,28 +4334,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4030
4334
  error: null
4031
4335
  };
4032
4336
  }
4033
- static async fromFile({ outputRoot = process.cwd(), validate = true, logger }) {
4337
+ /**
4338
+ * Load and merge MCP source files across the configured input roots.
4339
+ *
4340
+ * `inputRoots` entries are the source trees themselves (e.g.
4341
+ * `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
4342
+ * `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
4343
+ * legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
4344
+ * source tree and the first hit is loaded. Roots that have no candidate
4345
+ * contribute nothing.
4346
+ *
4347
+ * Cross-root behavior: the parsed JSON objects are folded left-to-right
4348
+ * with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
4349
+ * server name (one level deep) and replace every other value atomically.
4350
+ * With one root, this delegates to `fromFile` so JSONC formatting and the
4351
+ * actual candidate path are preserved. With multiple roots, each source is
4352
+ * parsed and schema-validated before merging so failures name the originating
4353
+ * file. The merged object is necessarily synthetic, serialized JSON anchored
4354
+ * to the first root's recommended path.
4355
+ *
4356
+ * A multi-root configuration where only one root actually supplies a file is
4357
+ * treated as the single-root case: nothing is merged, so the original file
4358
+ * content is kept verbatim (preserving JSONC comments) and the instance is
4359
+ * anchored at the root that supplied it rather than at the primary root's
4360
+ * recommended path.
4361
+ *
4362
+ * When no root supplies any candidate, this falls back to reading the
4363
+ * primary root's recommended path so the underlying file-not-found error
4364
+ * matches the single-root behavior of `fromFile`.
4365
+ */
4366
+ static async fromRoots({ inputRoots, validate = true, logger }) {
4367
+ if (inputRoots.length === 1) {
4368
+ const [primary] = inputRoots;
4369
+ return this.fromFile({
4370
+ outputRoot: dirname(primary),
4371
+ relativeDirPath: basename(primary),
4372
+ validate,
4373
+ logger
4374
+ });
4375
+ }
4034
4376
  const paths = this.getSettablePaths();
4377
+ const rootSources = [];
4378
+ for (const root of inputRoots) {
4379
+ const parent = dirname(root);
4380
+ const treeName = basename(root);
4381
+ const found = await findFirstExistingCandidate({
4382
+ paths,
4383
+ outputRoot: parent,
4384
+ overrideDirPath: treeName
4385
+ });
4386
+ if (found === void 0) continue;
4387
+ const { filePath, candidate } = found;
4388
+ if (filePath.endsWith(".mcp.json")) {
4389
+ const recommendedPath = join(parent, treeName, paths.recommended.relativeFilePath);
4390
+ logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4391
+ }
4392
+ const fileContent = await readFileContent(filePath);
4393
+ let parsed;
4394
+ try {
4395
+ parsed = parseJsonc(fileContent);
4396
+ if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
4397
+ if (validate) {
4398
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
4399
+ if (!result.success) throw result.error;
4400
+ }
4401
+ } catch (error) {
4402
+ throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
4403
+ }
4404
+ rootSources.push({
4405
+ record: parsed,
4406
+ outputRoot: parent,
4407
+ relativeDirPath: candidate.relativeDirPath,
4408
+ relativeFilePath: candidate.relativeFilePath,
4409
+ fileContent
4410
+ });
4411
+ }
4412
+ if (rootSources.length === 0) {
4413
+ const primary = inputRoots[0];
4414
+ return this.fromFile({
4415
+ outputRoot: dirname(primary),
4416
+ relativeDirPath: basename(primary),
4417
+ validate,
4418
+ logger
4419
+ });
4420
+ }
4421
+ const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
4422
+ if (onlySource !== void 0) return new RulesyncMcp({
4423
+ outputRoot: onlySource.outputRoot,
4424
+ relativeDirPath: onlySource.relativeDirPath,
4425
+ relativeFilePath: onlySource.relativeFilePath,
4426
+ fileContent: onlySource.fileContent,
4427
+ validate
4428
+ });
4429
+ const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
4430
+ base: acc,
4431
+ overlay: next.record
4432
+ }), {});
4433
+ const primary = inputRoots[0];
4434
+ return new RulesyncMcp({
4435
+ outputRoot: dirname(primary),
4436
+ relativeDirPath: basename(primary),
4437
+ relativeFilePath: paths.recommended.relativeFilePath,
4438
+ fileContent: JSON.stringify(merged, null, 2),
4439
+ validate
4440
+ });
4441
+ }
4442
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
4443
+ const paths = this.getSettablePaths();
4444
+ const overrideDirPath = relativeDirPath;
4035
4445
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
4036
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4446
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4447
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
4037
4448
  if (!await fileExists(filePath)) continue;
4038
4449
  if (candidate.relativeFilePath === ".mcp.json") {
4039
- const recommendedPath = join(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
4450
+ const recommendedPath = join(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
4040
4451
  logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4041
4452
  }
4042
4453
  const fileContent = await readFileContent(filePath);
4043
4454
  return new RulesyncMcp({
4044
4455
  outputRoot,
4045
- relativeDirPath: candidate.relativeDirPath,
4456
+ relativeDirPath: candidateDirPath,
4046
4457
  relativeFilePath: candidate.relativeFilePath,
4047
4458
  fileContent,
4048
4459
  validate
4049
4460
  });
4050
4461
  }
4051
- const fileContent = await readFileContent(join(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath));
4462
+ const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
4463
+ const fileContent = await readFileContent(join(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
4052
4464
  return new RulesyncMcp({
4053
4465
  outputRoot,
4054
- relativeDirPath: paths.recommended.relativeDirPath,
4466
+ relativeDirPath: fallbackDirPath,
4055
4467
  relativeFilePath: paths.recommended.relativeFilePath,
4056
4468
  fileContent,
4057
4469
  validate
@@ -5246,15 +5658,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5246
5658
  error: null
5247
5659
  };
5248
5660
  }
5249
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5661
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5250
5662
  const paths = RulesyncPermissions.getSettablePaths();
5663
+ const overrideDirPath = relativeDirPath;
5251
5664
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5252
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
5665
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
5666
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
5253
5667
  if (!await fileExists(filePath)) continue;
5254
5668
  const fileContent = await readFileContent(filePath);
5255
5669
  return new RulesyncPermissions({
5256
5670
  outputRoot,
5257
- relativeDirPath: candidate.relativeDirPath,
5671
+ relativeDirPath: candidateDirPath,
5258
5672
  relativeFilePath: candidate.relativeFilePath,
5259
5673
  fileContent,
5260
5674
  validate
@@ -5418,8 +5832,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5418
5832
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5419
5833
  };
5420
5834
  }
5421
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5422
- const filePath = join(outputRoot, this.getSettablePaths().recommended.relativeDirPath, relativeFilePath);
5835
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
5836
+ const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
5837
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
5423
5838
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5424
5839
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5425
5840
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5432,7 +5847,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5432
5847
  };
5433
5848
  return new RulesyncRule({
5434
5849
  outputRoot,
5435
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
5850
+ relativeDirPath: dirPath,
5436
5851
  relativeFilePath,
5437
5852
  frontmatter: validatedFrontmatter,
5438
5853
  body: content.trim(),
@@ -5680,7 +6095,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5680
6095
  "disable-model-invocation": z.optional(z.boolean()),
5681
6096
  "user-invocable": z.optional(z.boolean()),
5682
6097
  enabled: z.optional(z.boolean()),
5683
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
6098
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
6099
+ license: z.optional(z.unknown()),
6100
+ compatibility: z.optional(z.unknown()),
6101
+ metadata: z.optional(z.unknown()),
6102
+ version: z.optional(z.unknown())
5684
6103
  })),
5685
6104
  grokcli: z.optional(z.looseObject({
5686
6105
  "disable-model-invocation": z.optional(z.boolean()),
@@ -5856,8 +6275,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5856
6275
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5857
6276
  };
5858
6277
  }
5859
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5860
- const filePath = join(outputRoot, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath);
6278
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
6279
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
6280
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
5861
6281
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5862
6282
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5863
6283
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5865,7 +6285,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5865
6285
  const filename = basename(relativeFilePath);
5866
6286
  return new RulesyncSubagent({
5867
6287
  outputRoot,
5868
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6288
+ relativeDirPath: dirPath,
5869
6289
  relativeFilePath: filename,
5870
6290
  frontmatter: result.data,
5871
6291
  body: content.trim()
@@ -5875,16 +6295,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5875
6295
  //#endregion
5876
6296
  //#region src/features/skills/skills-utils.ts
5877
6297
  /**
5878
- * Returns the set of local skill directory names (excluding `.curated`).
6298
+ * Returns the set of local skill directory names (excluding `.curated`)
6299
+ * from a rulesync source tree (e.g. `/repo/.rulesync` or
6300
+ * `/repo/.rulesync.local`).
5879
6301
  */
5880
- async function getLocalSkillDirNames(outputRoot) {
5881
- const skillsDir = join(outputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
6302
+ async function getLocalSkillDirNames(sourceTree) {
6303
+ const skillsDir = join(sourceTree, SKILLS_FEATURE_SUBDIR);
5882
6304
  const names = /* @__PURE__ */ new Set();
5883
6305
  if (!await directoryExists(skillsDir)) return names;
5884
6306
  const dirPaths = await findFilesByGlobs(join(skillsDir, "*"), { type: "dir" });
5885
6307
  for (const dirPath of dirPaths) {
5886
6308
  const name = basename(dirPath);
5887
- if (name === basename(RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH)) continue;
6309
+ if (name === basename(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
5888
6310
  names.add(name);
5889
6311
  }
5890
6312
  return names;
@@ -6197,15 +6619,49 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6197
6619
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6198
6620
  }
6199
6621
  //#endregion
6622
+ //#region src/utils/control-characters.ts
6623
+ /**
6624
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
6625
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
6626
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
6627
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
6628
+ * settings file must never reach the terminal with these intact: they let the
6629
+ * text forge log lines, reorder what is printed around them, or inject escape
6630
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
6631
+ * neutral characters beside them, so they go too — a diagnostic line is not the
6632
+ * place to preserve the typography of a right-to-left name.
6633
+ */
6634
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
6635
+ /**
6636
+ * Removes every control character from `text` so it is safe to splice into a
6637
+ * log line or other terminal output.
6638
+ */
6639
+ function stripControlCharacters(text) {
6640
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
6641
+ }
6642
+ //#endregion
6200
6643
  //#region src/types/feature-processor.ts
6201
6644
  var FeatureProcessor = class {
6202
6645
  outputRoot;
6203
- inputRoot;
6646
+ /**
6647
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
6648
+ * is a source tree itself — the directory that directly contains feature
6649
+ * subdirectories (`rules/`, `commands/`, …) and single-file features
6650
+ * (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
6651
+ * per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
6652
+ *
6653
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
6654
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
6655
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
6656
+ * reaches a processor — every internal consumer only ever sees the
6657
+ * plural form, with the source tree already resolved.
6658
+ */
6659
+ inputRoots;
6204
6660
  dryRun;
6205
6661
  logger;
6206
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
6662
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6207
6663
  this.outputRoot = outputRoot;
6208
- this.inputRoot = inputRoot;
6664
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
6209
6665
  this.dryRun = dryRun;
6210
6666
  this.logger = logger;
6211
6667
  }
@@ -6272,6 +6728,198 @@ var FeatureProcessor = class {
6272
6728
  return orphanFiles.length;
6273
6729
  }
6274
6730
  };
6731
+ /**
6732
+ * Messages already reported for a given logger.
6733
+ *
6734
+ * One `generate` run constructs a single-file processor per tool target and
6735
+ * per output root — more than twenty times for `--targets "*"` — and each one
6736
+ * re-resolves the same roots. Keying on the logger — created once per run, and
6737
+ * once per test — keeps the shadowing warning to a single line instead of
6738
+ * repeating it for every target. `--watch` reuses one logger across runs, so
6739
+ * {@link resetRootShadowingWarnings} clears the set at the start of each one.
6740
+ */
6741
+ const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
6742
+ /**
6743
+ * Forget which shadowing warnings have already been reported.
6744
+ *
6745
+ * `generate` calls this once per run. Without it, `--watch` reuses one logger
6746
+ * for the whole session, so the warning would be printed on the first
6747
+ * generation and never again — the opposite of why it is a warning, since
6748
+ * `--watch` is exactly when an overlay is most likely to be added or edited.
6749
+ */
6750
+ function resetRootShadowingWarnings({ logger }) {
6751
+ warnedRootShadowingByLogger.delete(logger);
6752
+ }
6753
+ /**
6754
+ * Return the last input root that contains any of the given `relativePaths`,
6755
+ * or `undefined` when none of the roots has any of them. Used by single-file
6756
+ * features (hooks, permissions, ignore) to implement the "later root wins
6757
+ * the whole file" merge policy without materializing the file contents.
6758
+ *
6759
+ * `relativePaths` accepts a small list so features that historically read
6760
+ * either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
6761
+ * plus `.rulesync/mcp.json`) can preserve that resolution order per root.
6762
+ * A root counts as "having" the file as long as at least one candidate path
6763
+ * is present.
6764
+ */
6765
+ async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
6766
+ let winner;
6767
+ const rootsWithFile = [];
6768
+ for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists(join(root, relativePath))) {
6769
+ winner = root;
6770
+ rootsWithFile.push(root);
6771
+ break;
6772
+ }
6773
+ if (rootsWithFile.length > 1 && winner !== void 0) {
6774
+ const shadowed = rootsWithFile.slice(0, -1);
6775
+ const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
6776
+ let warnedMessages = warnedRootShadowingByLogger.get(logger);
6777
+ if (warnedMessages === void 0) {
6778
+ warnedMessages = /* @__PURE__ */ new Set();
6779
+ warnedRootShadowingByLogger.set(logger, warnedMessages);
6780
+ }
6781
+ if (!warnedMessages.has(message)) {
6782
+ warnedMessages.add(message);
6783
+ logger.warn(message);
6784
+ }
6785
+ }
6786
+ return winner;
6787
+ }
6788
+ /**
6789
+ * Merge per-root result lists into a single ordered list, keeping the
6790
+ * later root's entry when two roots produced an item with the same
6791
+ * identity. Identity is intentionally provided by the caller so
6792
+ * per-feature nuances (case-insensitive filesystems, directory names for
6793
+ * skills, server names for MCP) live next to the feature that owns them.
6794
+ *
6795
+ * The returned list preserves the FIRST appearance order of each identity —
6796
+ * items in the earliest root keep their position, but their content is
6797
+ * replaced by the last root that provided the same identity. This matches
6798
+ * the "overlay" mental model: an overlay changes content, not order.
6799
+ */
6800
+ function mergeByIdentity({ perRoot, identity }) {
6801
+ const order = [];
6802
+ const winnerByKey = /* @__PURE__ */ new Map();
6803
+ for (const rootItems of perRoot) for (const item of rootItems) {
6804
+ const key = identity(item);
6805
+ if (!winnerByKey.has(key)) order.push(key);
6806
+ winnerByKey.set(key, item);
6807
+ }
6808
+ return order.map((key) => winnerByKey.get(key));
6809
+ }
6810
+ /**
6811
+ * The key two spellings share when a case-insensitive filesystem would give
6812
+ * them one file. `toLowerCase()` is locale-independent (unlike
6813
+ * `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
6814
+ * locale), and the NFC pass folds the composed and decomposed spellings of an
6815
+ * accented name — which macOS also resolves to a single directory —
6816
+ * onto each other.
6817
+ *
6818
+ * This is simple lowercasing rather than full Unicode case folding, so it is
6819
+ * deliberately narrower than what a filesystem considers one file: a Greek
6820
+ * final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
6821
+ * trailing dot is stripped all still produce distinct keys. Those pairs keep
6822
+ * the pre-existing behavior (both are imported, and the later one wins on the
6823
+ * filesystem); folding them here would instead drop names that a
6824
+ * case-sensitive filesystem keeps genuinely apart.
6825
+ */
6826
+ function caseFoldIdentity(identity) {
6827
+ return identity.normalize("NFC").toLowerCase();
6828
+ }
6829
+ /**
6830
+ * Group spellings by their case-folded identity, keeping every original
6831
+ * spelling. On a case-sensitive filesystem one identity can cover several
6832
+ * spellings at once, and the caller needs them all to describe a collision
6833
+ * accurately.
6834
+ */
6835
+ function groupSpellingsByCaseFoldedIdentity(spellings) {
6836
+ const grouped = /* @__PURE__ */ new Map();
6837
+ for (const spelling of spellings) {
6838
+ const identity = caseFoldIdentity(spelling);
6839
+ const existing = grouped.get(identity);
6840
+ if (existing === void 0) grouped.set(identity, [spelling]);
6841
+ else existing.push(spelling);
6842
+ }
6843
+ return grouped;
6844
+ }
6845
+ /**
6846
+ * Build the warning emitted when a `.curated/` entry and a local entry in the
6847
+ * same tree differ only in case.
6848
+ *
6849
+ * `.curated/` is expanded from a declarative source (an external Git repository
6850
+ * or npm package), so its names are untrusted input; both sides are stripped of
6851
+ * control characters before they reach the terminal.
6852
+ *
6853
+ * The winning local spelling is the LAST one, matching the precedence
6854
+ * {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
6855
+ * that folds onto the same identity is listed too, so the message never names a
6856
+ * spelling that loses.
6857
+ */
6858
+ function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
6859
+ const winner = localSpellings[localSpellings.length - 1] ?? "";
6860
+ const shadowed = localSpellings.slice(0, -1);
6861
+ const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
6862
+ 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;
6863
+ }
6864
+ /**
6865
+ * Merge artifacts whose filenames are case-insensitive identities, warning
6866
+ * when distinct spellings collapse to the same key. Exact-name overlays are
6867
+ * intentional and remain quiet; only case-only ambiguity is diagnosed.
6868
+ *
6869
+ * Precedence here is the opposite of {@link ClaimedIdentities}: this merges
6870
+ * overlay roots, where the LAST entry wins, while the tool-side loaders keep
6871
+ * the FIRST root to claim a name.
6872
+ */
6873
+ function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
6874
+ const spellingByKey = /* @__PURE__ */ new Map();
6875
+ const warnedKeys = /* @__PURE__ */ new Set();
6876
+ return mergeByIdentity({
6877
+ perRoot,
6878
+ identity: (item) => {
6879
+ const spelling = identity(item);
6880
+ const key = caseFoldIdentity(spelling);
6881
+ const previousSpelling = spellingByKey.get(key);
6882
+ if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
6883
+ logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
6884
+ warnedKeys.add(key);
6885
+ }
6886
+ if (previousSpelling === void 0) spellingByKey.set(key, spelling);
6887
+ return key;
6888
+ }
6889
+ });
6890
+ }
6891
+ /**
6892
+ * Tracks the import identities already claimed while scanning, folding case
6893
+ * through {@link caseFoldIdentity}.
6894
+ *
6895
+ * The tool-side loaders scan several roots in precedence order and keep the
6896
+ * first spelling of each identity. Comparing those identities exactly lets
6897
+ * `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
6898
+ * since macOS and Windows resolve the two written-back directories to a
6899
+ * single one, the shared Agent Skills copy lands last and overwrites the
6900
+ * tool-specific one — inverting the precedence the roots were ordered by.
6901
+ *
6902
+ * The FIRST claimer wins, which is the opposite of the overlay precedence in
6903
+ * {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
6904
+ * order, so the earliest one to name a skill is the one that should keep it.
6905
+ */
6906
+ var ClaimedIdentities = class {
6907
+ claimByKey = /* @__PURE__ */ new Map();
6908
+ /**
6909
+ * Claim `identity` on behalf of `source`. Returns `null` when nothing held
6910
+ * it yet, or the standing claim when something did.
6911
+ */
6912
+ claim({ identity, source }) {
6913
+ const key = caseFoldIdentity(identity);
6914
+ const claimed = this.claimByKey.get(key);
6915
+ if (claimed !== void 0) return claimed;
6916
+ this.claimByKey.set(key, {
6917
+ spelling: identity,
6918
+ source
6919
+ });
6920
+ return null;
6921
+ }
6922
+ };
6275
6923
  //#endregion
6276
6924
  //#region src/constants/amp-paths.ts
6277
6925
  const AMP_DIR = ".amp";
@@ -8594,10 +9242,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8594
9242
  toolTarget;
8595
9243
  global;
8596
9244
  getFactory;
8597
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
9245
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
8598
9246
  super({
8599
9247
  outputRoot,
8600
- inputRoot,
9248
+ inputRoots,
8601
9249
  dryRun,
8602
9250
  logger
8603
9251
  });
@@ -8634,11 +9282,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8634
9282
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8635
9283
  }
8636
9284
  /**
8637
- * Implementation of abstract method from Processor
8638
- * Load and parse rulesync check files from .rulesync/checks/ directory
9285
+ * Load check files from a single source-tree's `checks/` subtree.
9286
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
9287
+ * `/repo/.rulesync.local`).
8639
9288
  */
8640
- async loadRulesyncFiles() {
8641
- const checksDir = join(this.inputRoot, RulesyncCheck.getSettablePaths().relativeDirPath);
9289
+ async loadRulesyncFilesForRoot(sourceTree) {
9290
+ const treeParent = dirname(sourceTree);
9291
+ const treeName = basename(sourceTree);
9292
+ const treeChecksDirPath = join(treeName, CHECKS_FEATURE_SUBDIR);
9293
+ const checksDir = join(sourceTree, CHECKS_FEATURE_SUBDIR);
8642
9294
  if (!await directoryExists(checksDir)) {
8643
9295
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8644
9296
  return [];
@@ -8654,7 +9306,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8654
9306
  const filepath = join(checksDir, mdFile);
8655
9307
  try {
8656
9308
  const rulesyncCheck = await RulesyncCheck.fromFile({
8657
- outputRoot: this.inputRoot,
9309
+ outputRoot: treeParent,
9310
+ relativeDirPath: treeChecksDirPath,
8658
9311
  relativeFilePath: mdFile,
8659
9312
  validate: true
8660
9313
  });
@@ -8665,6 +9318,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8665
9318
  continue;
8666
9319
  }
8667
9320
  }
9321
+ return rulesyncChecks;
9322
+ }
9323
+ /**
9324
+ * Implementation of abstract method from Processor
9325
+ * Load and parse rulesync check files from every configured input root's
9326
+ * `.rulesync/checks/` directory, merging by relative file path so a check
9327
+ * from a later root replaces the earlier root's copy.
9328
+ */
9329
+ async loadRulesyncFiles() {
9330
+ const rulesyncChecks = mergeByCaseInsensitiveIdentity({
9331
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
9332
+ identity: (check) => check.getRelativeFilePath(),
9333
+ artifactName: "check",
9334
+ logger: this.logger
9335
+ });
8668
9336
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8669
9337
  return rulesyncChecks;
8670
9338
  }
@@ -10143,18 +10811,23 @@ function commandSlug(relativeFilePath) {
10143
10811
  return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10144
10812
  }
10145
10813
  /**
10146
- * Whether a rulesync command exists whose slug matches `dirName`.
10814
+ * Whether a rulesync command exists whose slug matches `dirName` in any of
10815
+ * the configured input roots.
10816
+ *
10817
+ * `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
10818
+ * `/repo/.rulesync.local`), so commands live directly under
10819
+ * `<sourceTree>/commands/`.
10147
10820
  *
10148
10821
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10149
10822
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10150
10823
  * current command slug is owned by the commands feature, so the skills
10151
10824
  * 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.
10825
+ * skill. Once the command is removed from every source tree's `commands/`
10826
+ * directory, the directory stops matching and the skills feature cleans
10827
+ * it up as a regular orphan.
10155
10828
  */
10156
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10157
- return (await findFilesByGlobs(join(inputRoot, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"))).some((filePath) => commandSlug(basename(filePath)) === dirName);
10829
+ async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
10830
+ return (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug(basename(filePath)) === dirName);
10158
10831
  }
10159
10832
  //#endregion
10160
10833
  //#region src/features/commands/devin-command.ts
@@ -10844,11 +11517,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10844
11517
  * output is lost — so this warns rather than failing the run the way the
10845
11518
  * Hermes check does, where the two surfaces really do write the same path.
10846
11519
  */
10847
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
11520
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10848
11521
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => basename(command.getRelativeFilePath(), ".md")));
10849
11522
  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));
11523
+ 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
11524
  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
11525
  }
10854
11526
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -11620,7 +12292,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11620
12292
  static getExtraSharedWritePaths() {
11621
12293
  return getHermesagentSharedConfigWritePaths();
11622
12294
  }
11623
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
12295
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11624
12296
  const commandSlugs = /* @__PURE__ */ new Set();
11625
12297
  const commandOrigins = /* @__PURE__ */ new Map();
11626
12298
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11632,13 +12304,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11632
12304
  commandOrigins.set(slug, origin);
11633
12305
  commandSlugs.add(slug);
11634
12306
  }
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));
12307
+ const skillsByName = /* @__PURE__ */ new Map();
12308
+ for (const rootPath of inputRoots) {
12309
+ const skillsRoot = join(rootPath, SKILLS_FEATURE_SUBDIR);
12310
+ const skillFiles = await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"));
12311
+ const loaded = await Promise.all(skillFiles.map(async (filePath) => {
12312
+ const dirName = toPosixPath(relative(skillsRoot, dirname(filePath)));
12313
+ return {
12314
+ rulesyncSkill: await RulesyncSkill.fromDir({
12315
+ outputRoot: rootPath,
12316
+ relativeDirPath: SKILLS_FEATURE_SUBDIR,
12317
+ dirName
12318
+ }),
12319
+ dirName,
12320
+ rootPath
12321
+ };
12322
+ }));
12323
+ for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
12324
+ }
12325
+ const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
11642
12326
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11643
12327
  }
11644
12328
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11771,6 +12455,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11771
12455
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11772
12456
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11773
12457
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
12458
+ const JUNIE_RULES_DIR_NAME = "rules";
12459
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11774
12460
  //#endregion
11775
12461
  //#region src/features/commands/junie-command.ts
11776
12462
  const JunieCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()) });
@@ -13731,10 +14417,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13731
14417
  global;
13732
14418
  getFactory;
13733
14419
  flattenedCommandNaming;
13734
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
14420
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
13735
14421
  super({
13736
14422
  outputRoot,
13737
- inputRoot,
14423
+ inputRoots,
13738
14424
  dryRun,
13739
14425
  logger
13740
14426
  });
@@ -13749,7 +14435,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13749
14435
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13750
14436
  const factory = this.getFactory(this.toolTarget);
13751
14437
  await factory.class.validateRulesyncCommands?.({
13752
- inputRoot: this.inputRoot,
14438
+ inputRoots: this.inputRoots,
13753
14439
  rulesyncCommands,
13754
14440
  logger: this.logger
13755
14441
  });
@@ -13801,16 +14487,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13801
14487
  return rel;
13802
14488
  }
13803
14489
  /**
13804
- * Implementation of abstract method from FeatureProcessor
13805
- * Load and parse rulesync command files from .rulesync/commands/ directory
14490
+ * Load rulesync command files from a single source-tree's `commands/`
14491
+ * subtree. `sourceTree` is the source tree itself (e.g.
14492
+ * `/repo/.rulesync` or `/repo/.rulesync.local`).
13806
14493
  */
13807
- async loadRulesyncFiles() {
13808
- const basePath = join(this.inputRoot, RulesyncCommand.getSettablePaths().relativeDirPath);
14494
+ async loadRulesyncFilesForRoot(sourceTree) {
14495
+ const treeParent = dirname(sourceTree);
14496
+ const treeName = basename(sourceTree);
14497
+ const treeCommandsDirPath = join(treeName, COMMANDS_FEATURE_SUBDIR);
14498
+ const basePath = join(sourceTree, COMMANDS_FEATURE_SUBDIR);
13809
14499
  const rulesyncCommandPaths = await findFilesByGlobs(join(basePath, "**", "*.md"));
13810
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13811
- outputRoot: this.inputRoot,
14500
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
14501
+ outputRoot: treeParent,
14502
+ relativeDirPath: treeCommandsDirPath,
13812
14503
  relativeFilePath: this.safeRelativePath(basePath, path)
13813
14504
  })));
14505
+ }
14506
+ /**
14507
+ * Implementation of abstract method from FeatureProcessor
14508
+ * Load and parse rulesync command files from every configured input root's
14509
+ * `.rulesync/commands/` directory, merging by relative path so a command
14510
+ * with the same target path from a later root replaces the earlier root's
14511
+ * copy.
14512
+ */
14513
+ async loadRulesyncFiles() {
14514
+ const rulesyncCommands = mergeByCaseInsensitiveIdentity({
14515
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
14516
+ identity: (command) => command.getRelativeFilePath(),
14517
+ artifactName: "command",
14518
+ logger: this.logger
14519
+ });
13814
14520
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13815
14521
  return rulesyncCommands;
13816
14522
  }
@@ -19450,6 +20156,7 @@ function vibeEntryToCanonicalDef(raw) {
19450
20156
  const entry = raw;
19451
20157
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19452
20158
  if (vibeEvent === void 0) return null;
20159
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19453
20160
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19454
20161
  const def = { type: "command" };
19455
20162
  if (typeof entry.command === "string") def.command = entry.command;
@@ -19998,10 +20705,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
19998
20705
  var HooksProcessor = class extends FeatureProcessor {
19999
20706
  toolTarget;
20000
20707
  global;
20001
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
20708
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
20002
20709
  super({
20003
20710
  outputRoot,
20004
- inputRoot,
20711
+ inputRoots,
20005
20712
  dryRun,
20006
20713
  logger
20007
20714
  });
@@ -20011,9 +20718,17 @@ var HooksProcessor = class extends FeatureProcessor {
20011
20718
  this.global = global;
20012
20719
  }
20013
20720
  async loadRulesyncFiles() {
20721
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
20722
+ const sourceTree = await pickLastRootWithFile({
20723
+ inputRoots: this.inputRoots,
20724
+ relativePaths,
20725
+ logger: this.logger,
20726
+ artifactName: "The hooks file"
20727
+ }) ?? this.inputRoots[0];
20014
20728
  try {
20015
20729
  return [await RulesyncHooks.fromFile({
20016
- outputRoot: this.inputRoot,
20730
+ outputRoot: dirname(sourceTree),
20731
+ relativeDirPath: basename(sourceTree),
20017
20732
  validate: true
20018
20733
  })];
20019
20734
  } catch (error) {
@@ -21555,10 +22270,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21555
22270
  getFactory;
21556
22271
  featureOptions;
21557
22272
  global;
21558
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
22273
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
21559
22274
  super({
21560
22275
  outputRoot,
21561
- inputRoot,
22276
+ inputRoots,
21562
22277
  dryRun,
21563
22278
  logger
21564
22279
  });
@@ -21575,11 +22290,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21575
22290
  }
21576
22291
  /**
21577
22292
  * Implementation of abstract method from FeatureProcessor
21578
- * Load and parse rulesync ignore files from .rulesync/ignore/ directory
22293
+ *
22294
+ * Load and parse the rulesync ignore file. `inputRoots[i]` is a source
22295
+ * tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
22296
+ * lives directly inside it. The legacy `.rulesyncignore` is shared at the
22297
+ * project root, so it is intentionally not considered when choosing which
22298
+ * source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
22299
+ * for the chosen tree.
22300
+ *
22301
+ * When multiple input roots are configured, the last root that provides
22302
+ * an ignore file wins entirely (whole-file replacement — no line-level
22303
+ * merge in this slice; see the "Deliberately out of scope" section of
22304
+ * the inputRoots plan for context). If no root has the file, fall back
22305
+ * to the primary root's path so the underlying `RulesyncIgnore.fromFile`
22306
+ * surfaces the same missing-file error it would in the single-root case.
21579
22307
  */
21580
22308
  async loadRulesyncFiles() {
22309
+ const paths = RulesyncIgnore.getSettablePaths();
22310
+ const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
22311
+ const sourceTree = await pickLastRootWithFile({
22312
+ inputRoots: this.inputRoots,
22313
+ relativePaths,
22314
+ logger: this.logger,
22315
+ artifactName: "The ignore file (.aiignore)"
22316
+ }) ?? this.inputRoots[0];
21581
22317
  try {
21582
- return [await RulesyncIgnore.fromFile({ outputRoot: this.inputRoot })];
22318
+ return [await RulesyncIgnore.fromFile({
22319
+ outputRoot: dirname(sourceTree),
22320
+ relativeDirPath: basename(sourceTree)
22321
+ })];
21583
22322
  } catch (error) {
21584
22323
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21585
22324
  return [];
@@ -26614,11 +27353,86 @@ function disabledNamesOf(config) {
26614
27353
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26615
27354
  }
26616
27355
  /**
27356
+ * The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
27357
+ * `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
27358
+ * always POSIX-separated.
27359
+ */
27360
+ const ROVODEV_PROJECT_MCP_CONFIG_POINTER = posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
27361
+ /**
27362
+ * The keys that make an entry in `mcp.json` something Rovo Dev can start:
27363
+ * a local process, or a remote endpoint under either spelling the canonical
27364
+ * config accepts.
27365
+ */
27366
+ const MCP_SERVER_ENDPOINT_KEYS = [
27367
+ "command",
27368
+ "url",
27369
+ "httpUrl"
27370
+ ];
27371
+ function normalizeMcpConfigPathValue(value) {
27372
+ return toPosixPath(value).replace(/^\.\//, "");
27373
+ }
27374
+ /**
27375
+ * Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
27376
+ * and report whether the block gained a value it did not already carry.
27377
+ *
27378
+ * Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
27379
+ * directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
27380
+ * config points at it — the Bitbucket Agentic Pipelines guide documents
27381
+ * registering the server and setting the pointer as two required steps. Global
27382
+ * scope is left alone: there the default already resolves to the file rulesync
27383
+ * writes.
27384
+ *
27385
+ * The pointer names one config rather than merging with the default, so it is
27386
+ * written only when this project actually has a Rovo Dev server to run — a
27387
+ * server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
27388
+ * generated empty, and pointing at it would take away the user's global
27389
+ * servers for this repository in exchange for nothing.
27390
+ *
27391
+ * That condition can also stop holding after the fact, once the last server is
27392
+ * removed from the canonical config or switched off. Rulesync does not take
27393
+ * the pointer back out — it cannot tell its own past value from a user who
27394
+ * typed the same string — but it says so, since the file is now a live setting
27395
+ * that resolves to nothing.
27396
+ *
27397
+ * A pointer the user aimed somewhere else is theirs, not ours: overwriting it
27398
+ * would silently redirect Rovo Dev away from a file they chose. It is named in
27399
+ * a warning instead, because the generated `mcp.json` is unread while it
27400
+ * stands.
27401
+ *
27402
+ * Whatever the outcome, it is logged: writing the pointer turns servers that
27403
+ * were generated-but-never-read into servers Rovo Dev actually spawns, and
27404
+ * points it away from the global MCP config, so it is not something to do
27405
+ * quietly.
27406
+ *
27407
+ * @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
27408
+ * @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
27409
+ */
27410
+ function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
27411
+ if (global) return false;
27412
+ const existing = existingMcp.mcpConfigPath;
27413
+ const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
27414
+ if (!hasLiveServers) {
27415
+ 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.`);
27416
+ return false;
27417
+ }
27418
+ if (existing === void 0) {
27419
+ existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
27420
+ 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.`);
27421
+ return true;
27422
+ }
27423
+ if (pointsAtGeneratedFile) return false;
27424
+ 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}".`);
27425
+ return false;
27426
+ }
27427
+ /**
26617
27428
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26618
27429
  * `~/.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.
27430
+ * Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
27431
+ * which rulesync authors in project scope when the key is absent and this
27432
+ * project has a server to run (see `applyProjectMcpConfigPointer`). The block
27433
+ * is recomputed from the existing one, so user keys (`allowedMcpServers`,
27434
+ * ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
27435
+ * servers rulesync does not manage all survive.
26622
27436
  */
26623
27437
  var RovodevMcpConfigYaml = class extends ToolFile {
26624
27438
  isDeletable() {
@@ -26751,7 +27565,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26751
27565
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26752
27566
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26753
27567
  else delete existingMcp.disabledMcpServers;
26754
- if (mergedDisabled.length === 0 && existingContent.trim() === "") return [];
27568
+ const wrotePointer = applyProjectMcpConfigPointer({
27569
+ existingMcp,
27570
+ global,
27571
+ hasLiveServers: managedNames.filter((name) => {
27572
+ if (disabledNames.includes(name)) return false;
27573
+ const server = servers[name];
27574
+ return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
27575
+ }).length > 0,
27576
+ logger
27577
+ });
27578
+ if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
26755
27579
  const fileContent = applySharedConfigPatch({
26756
27580
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26757
27581
  feature: "mcp",
@@ -27844,10 +28668,10 @@ var McpProcessor = class extends FeatureProcessor {
27844
28668
  toolTarget;
27845
28669
  global;
27846
28670
  getFactory;
27847
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
28671
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
27848
28672
  super({
27849
28673
  outputRoot,
27850
- inputRoot,
28674
+ inputRoots,
27851
28675
  dryRun,
27852
28676
  logger
27853
28677
  });
@@ -27863,7 +28687,10 @@ var McpProcessor = class extends FeatureProcessor {
27863
28687
  */
27864
28688
  async loadRulesyncFiles() {
27865
28689
  try {
27866
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
28690
+ return [await RulesyncMcp.fromRoots({
28691
+ inputRoots: this.inputRoots,
28692
+ logger: this.logger
28693
+ })];
27867
28694
  } catch (error) {
27868
28695
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27869
28696
  return [];
@@ -29405,27 +30232,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
29405
30232
  return { permission };
29406
30233
  }
29407
30234
  //#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
30235
  //#region src/features/permissions/claudecode-permissions.ts
29430
30236
  /**
29431
30237
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -29511,8 +30317,8 @@ function deepMergeRecords(base, patch) {
29511
30317
  *
29512
30318
  * Deliberately NOT listed:
29513
30319
  * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
29514
- * `stripCommandExecutingSandboxPaths` refuses them in both scopes rather than
29515
- * emitting them under `--global`.
30320
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
30321
+ * rather than emitting them under `--global`.
29516
30322
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29517
30323
  * unit is the individual entry's mode, not the settings key, and the same
29518
30324
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29531,6 +30337,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29531
30337
  ["allowAppleEvents"]
29532
30338
  ];
29533
30339
  /**
30340
+ * `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
30341
+ * only from the settings file an organization deploys. Neither file rulesync
30342
+ * writes is that file, so they are dropped in **both** scopes — the `sandbox`
30343
+ * counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
30344
+ * for top-level `Managed` keys.
30345
+ *
30346
+ * Both only ever *narrow* the policy — they stop a lower-scoped file from
30347
+ * re-opening what managed settings blocked — so neither is trust-widening. They
30348
+ * are dropped rather than written for the opposite reason: written into a
30349
+ * project or user file they do nothing at all, and a `sandbox` block that reads
30350
+ * as though it locked the policy to managed values while Claude Code ignores it
30351
+ * is the more dangerous of the two failure modes.
30352
+ *
30353
+ * Import keeps them, unlike the command-executing paths: the value in an
30354
+ * existing `settings.json` was hand-written to be honored somewhere, and
30355
+ * round-tripping it preserves the author's intent for the day it moves into a
30356
+ * managed file. The cost is a warning on every generate until it is removed,
30357
+ * which the refusal message points at.
30358
+ *
30359
+ * @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
30360
+ * — "Scope: `Managed`"; the `network` entry says the same.
30361
+ */
30362
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
30363
+ /**
29534
30364
  * Walks `segments` from `root`, returning the record they name or `undefined` if
29535
30365
  * any step is missing or not a record. Shared by everything below that addresses
29536
30366
  * a `sandbox` path, so a nested path added to one of the tables is actually
@@ -29577,6 +30407,18 @@ function deleteSandboxPath({ target, path }) {
29577
30407
  return true;
29578
30408
  }
29579
30409
  /**
30410
+ * The one warning that names every trust-affecting setting this generate wrote
30411
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
30412
+ * matter, but the "review this as you would a hook" framing only needs saying
30413
+ * once, and repeating it per key buries the reasons in boilerplate.
30414
+ */
30415
+ function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
30416
+ if (entries.length === 0) return;
30417
+ const one = entries.length === 1;
30418
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
30419
+ 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}.`);
30420
+ }
30421
+ /**
29580
30422
  * The `permissions.defaultMode` values that start a session with fewer prompts
29581
30423
  * than the default. `plan` and `default` are absent because they do not widen
29582
30424
  * anything.
@@ -29589,14 +30431,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
29589
30431
  /**
29590
30432
  * The `permissions` fields that widen rather than restrict: a `defaultMode` that
29591
30433
  * 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.
30434
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
30435
+ * a shareable permissions file should not loosen the permission system quietly.
29594
30436
  */
29595
- function warnOnWideningPermissionFields({ fields, relativeFilePath, logger }) {
30437
+ function collectWideningPermissionFields({ fields }) {
30438
+ const entries = [];
29596
30439
  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'.`);
30440
+ if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
30441
+ label: `permissions.defaultMode: "${defaultMode}"`,
30442
+ reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
30443
+ });
29598
30444
  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'.`);
30445
+ if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
30446
+ label: "permissions.additionalDirectories",
30447
+ reason: "moves the boundary of what Claude Code may read and edit outside the project"
30448
+ });
30449
+ return entries;
29600
30450
  }
29601
30451
  /**
29602
30452
  * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
@@ -29613,6 +30463,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
29613
30463
  ["socatPath"]
29614
30464
  ];
29615
30465
  /**
30466
+ * The predicates the "which value actually widens?" tables are built from.
30467
+ * Each names the value that does *not* widen and reports everything else, never
30468
+ * the reverse: the override is authored JSONC, so a key can carry any value at
30469
+ * all, and one Claude Code coerces is still honored. Reporting an off-type value
30470
+ * keeps the warning fail-safe — silence has to mean "this cannot loosen
30471
+ * anything", not "this is not the type the table expected".
30472
+ */
30473
+ const isNotFalse = (value) => value !== false;
30474
+ const isNotTrue = (value) => value !== true;
30475
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
30476
+ const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
30477
+ /**
29616
30478
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
29617
30479
  * they let commands out of it, weaken the isolation it provides, or redirect
29618
30480
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -29632,77 +30494,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29632
30494
  {
29633
30495
  path: ["allowAppleEvents"],
29634
30496
  reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
29635
- widens: (value) => value === true
30497
+ widens: isNotFalse
29636
30498
  },
29637
30499
  {
29638
30500
  path: ["allowUnsandboxedCommands"],
29639
30501
  reason: "controls whether Claude may retry a blocked command outside the sandbox",
29640
- widens: (value) => value !== false
30502
+ widens: isNotFalse
29641
30503
  },
29642
30504
  {
29643
30505
  path: ["autoAllowBashIfSandboxed"],
29644
30506
  reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
29645
- widens: (value) => value !== false
30507
+ widens: isNotFalse
29646
30508
  },
29647
30509
  {
29648
30510
  path: ["enableWeakerNestedSandbox"],
29649
30511
  reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
29650
- widens: (value) => value === true
30512
+ widens: isNotFalse
29651
30513
  },
29652
30514
  {
29653
30515
  path: ["enableWeakerNetworkIsolation"],
29654
30516
  reason: "weakens the sandbox's network isolation on macOS",
29655
- widens: (value) => value === true
30517
+ widens: isNotFalse
29656
30518
  },
29657
30519
  {
29658
30520
  path: ["enabled"],
29659
30521
  reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
29660
- widens: (value) => value === true
30522
+ widens: isNotFalse
29661
30523
  },
29662
30524
  {
29663
30525
  path: ["excludedCommands"],
29664
30526
  reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
29665
- widens: (value) => !Array.isArray(value) || value.length > 0
30527
+ widens: isNonEmptyList
29666
30528
  },
29667
30529
  {
29668
30530
  path: ["filesystem", "allowRead"],
29669
30531
  reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
29670
- widens: (value) => !Array.isArray(value) || value.length > 0
30532
+ widens: isNonEmptyList
29671
30533
  },
29672
30534
  {
29673
30535
  path: ["filesystem", "allowWrite"],
29674
30536
  reason: "adds paths sandboxed commands may write to, outside the working directory",
29675
- widens: (value) => !Array.isArray(value) || value.length > 0
30537
+ widens: isNonEmptyList
29676
30538
  },
29677
30539
  {
29678
30540
  path: ["ignoreViolations"],
29679
30541
  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
30542
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
29681
30543
  },
29682
30544
  {
29683
30545
  path: ["network", "allowAllUnixSockets"],
29684
30546
  reason: "lets sandboxed commands connect to every Unix socket",
29685
- widens: (value) => value === true
30547
+ widens: isNotFalse
29686
30548
  },
29687
30549
  {
29688
30550
  path: ["network", "allowedDomains"],
29689
30551
  reason: "pre-allows domains sandboxed commands may reach without a prompt",
29690
- widens: (value) => !Array.isArray(value) || value.length > 0
30552
+ widens: isNonEmptyList
29691
30553
  },
29692
30554
  {
29693
30555
  path: ["network", "allowLocalBinding"],
29694
30556
  reason: "lets sandboxed commands bind local ports",
29695
- widens: (value) => value === true
30557
+ widens: isNotFalse
29696
30558
  },
29697
30559
  {
29698
30560
  path: ["network", "allowMachLookup"],
29699
30561
  reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
29700
- widens: (value) => !Array.isArray(value) || value.length > 0
30562
+ widens: isNonEmptyList
29701
30563
  },
29702
30564
  {
29703
30565
  path: ["network", "allowUnixSockets"],
29704
30566
  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
30567
+ widens: isNonEmptyList
29706
30568
  },
29707
30569
  {
29708
30570
  path: ["network", "httpProxyPort"],
@@ -29716,11 +30578,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29716
30578
  }
29717
30579
  ];
29718
30580
  /**
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.
30581
+ * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
30582
+ * the values are written, just not silently. Called on the filtered `sandbox`
30583
+ * so it never claims to be writing a path the scope filters dropped.
29722
30584
  */
29723
- function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger }) {
30585
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
30586
+ const entries = [];
29724
30587
  for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29725
30588
  const leaf = path.at(-1);
29726
30589
  if (leaf === void 0) continue;
@@ -29731,38 +30594,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
29731
30594
  if (parent === void 0) continue;
29732
30595
  const value = parent[leaf];
29733
30596
  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'.`);
30597
+ entries.push({
30598
+ label: `sandbox.${path.join(".")}`,
30599
+ reason
30600
+ });
29735
30601
  }
30602
+ return entries;
29736
30603
  }
30604
+ /** Paths that name an executable Claude Code runs. Refused in both scopes. */
30605
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
30606
+ paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
30607
+ 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.`
30608
+ };
29737
30609
  /**
29738
- * Copy of the authored `sandbox` override with the paths that name an
29739
- * executable removed, warning once per dropped path.
30610
+ * Paths Claude Code honors only from managed settings. Refused in both scopes,
30611
+ * like the command-executing paths, because managed settings are not a file
30612
+ * rulesync writes in either of them.
29740
30613
  */
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
- }
30614
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
30615
+ paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
30616
+ 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.`
30617
+ };
30618
+ /** Paths Claude Code honors only above project scope. Refused at project scope. */
30619
+ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
30620
+ paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
30621
+ 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.`
30622
+ };
29752
30623
  /**
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.
30624
+ * Copy of the authored `sandbox` override with every path of every passed
30625
+ * refusal removed, warning once per dropped path. Only the override copy is
30626
+ * filtered — a value already hand-written in the target file is left untouched,
30627
+ * matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
29757
30628
  */
29758
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
30629
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
29759
30630
  const filtered = structuredClone(sandbox);
29760
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
30631
+ for (const { paths, warn } of refusals) for (const path of paths) {
29761
30632
  if (!deleteSandboxPath({
29762
30633
  target: filtered,
29763
30634
  path
29764
30635
  })) 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.`);
30636
+ logger?.warn(warn({
30637
+ label: `sandbox.${path.join(".")}`,
30638
+ relativeFilePath
30639
+ }));
29766
30640
  }
29767
30641
  return filtered;
29768
30642
  }
@@ -29786,7 +30660,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29786
30660
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29787
30661
  * against cannot slip through a differently-spelled value.
29788
30662
  *
29789
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
30663
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29790
30664
  * value already in the target file is left untouched, which is why the warning
29791
30665
  * points at it.
29792
30666
  *
@@ -29953,6 +30827,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29953
30827
  allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
29954
30828
  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
30829
  autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
30830
+ claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
30831
+ 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",
30832
+ crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
29956
30833
  disableAllHooks: "controls whether hooks run at all",
29957
30834
  disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
29958
30835
  enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
@@ -29961,17 +30838,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29961
30838
  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
30839
  extraKnownMarketplaces: "registers plugin marketplace sources",
29963
30840
  httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
30841
+ 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
30842
  outputStyle: "replaces the system prompt every session runs with",
30843
+ 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",
30844
+ 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
30845
  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"
30846
+ skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
30847
+ skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
29967
30848
  };
29968
30849
  /**
29969
30850
  * The keys from the table above that only widen at one particular value.
29970
30851
  * `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 };
30852
+ * restricts; anything else re-opens it, and that is what a fetched override
30853
+ * could use to undo a user setting. The rest default to off, so only a value
30854
+ * other than the one that leaves them off is worth a line — and for the
30855
+ * list-valued and map-valued keys, only a non-empty one, since an empty list or
30856
+ * map excludes, announces and overrides nothing.
30857
+ */
30858
+ const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
30859
+ claudeMdExcludes: isNonEmptyList,
30860
+ companyAnnouncements: isNonEmptyList,
30861
+ crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
30862
+ disableSkillShellExecution: isNotTrue,
30863
+ modelOverrides: isNonEmptyMap,
30864
+ remoteControlAtStartup: isNotFalse,
30865
+ skipWebFetchPreflight: isNotFalse
30866
+ };
30867
+ /**
30868
+ * Top-level keys a project-scoped `.claude/settings.json` honors at one value
30869
+ * but ignores at another — the value-level counterpart of
30870
+ * {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
30871
+ * value is dropped at project scope for the same reason a wholly unhonored key
30872
+ * is: committing it would read as a policy that never applies.
30873
+ *
30874
+ * `remoteControlAtStartup` is the only entry whose honored value can be decided
30875
+ * from the value alone. Claude Code honors a `false` from project or local
30876
+ * settings — a repository may turn auto-connect off for its own checkout — but
30877
+ * ignores a `true`, so that a checked-in file cannot turn Remote Control on for
30878
+ * everyone who opens the repository.
30879
+ *
30880
+ * `crossSessionInbound` is on the same documented list but deliberately absent
30881
+ * here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
30882
+ * honored only when it is stricter than the one above it, which no per-value
30883
+ * predicate can decide without reading the user's own settings. It is warned
30884
+ * about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
30885
+ * `--global` its loosening value is honored outright.
30886
+ *
30887
+ * @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
30888
+ */
30889
+ const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
30890
+ ignored: isNotFalse,
30891
+ 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"
30892
+ } };
29975
30893
  /**
29976
30894
  * A key name is authored data that ends up in a log line, so strip the control
29977
30895
  * characters that would let it forge a line or hide the warnings beside it, and
@@ -29999,12 +30917,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
29999
30917
  /**
30000
30918
  * Copy of the authored top-level passthrough with the keys the target file
30001
30919
  * cannot honor removed, warning once per dropped key. Like
30002
- * `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a value
30920
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30003
30921
  * already hand-written in the target file is left untouched, which is why the
30004
30922
  * warning points at it.
30005
30923
  */
30006
30924
  function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30007
30925
  const filtered = {};
30926
+ const trustAffecting = [];
30008
30927
  for (const [key, value] of Object.entries(overrides)) {
30009
30928
  const shown = displayKey(key);
30010
30929
  const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
@@ -30020,11 +30939,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
30020
30939
  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
30940
  continue;
30022
30941
  }
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'.`);
30942
+ const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
30943
+ if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
30944
+ 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.`);
30945
+ continue;
30946
+ }
30947
+ const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
30948
+ if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
30949
+ label: shown,
30950
+ reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
30951
+ });
30025
30952
  filtered[key] = value;
30026
30953
  }
30027
- return filtered;
30954
+ return {
30955
+ filtered,
30956
+ trustAffecting
30957
+ };
30028
30958
  }
30029
30959
  const CLAUDE_PATH_RULE_ALIASES = {
30030
30960
  Write: "Edit",
@@ -30094,15 +31024,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30094
31024
  config,
30095
31025
  logger
30096
31026
  });
31027
+ const trustAffecting = [];
30097
31028
  const overridePermissions = config.claudecode?.permissions;
30098
31029
  if (overridePermissions && typeof overridePermissions === "object") {
30099
31030
  const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
30100
31031
  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
- });
31032
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
30106
31033
  settings.permissions = {
30107
31034
  ...settings.permissions,
30108
31035
  ...nonListFields
@@ -30110,25 +31037,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30110
31037
  }
30111
31038
  const overrideSandbox = config.claudecode?.sandbox;
30112
31039
  if (isPlainRecord(overrideSandbox)) {
30113
- const executableFreeSandbox = stripCommandExecutingSandboxPaths({
31040
+ const honorableSandbox = stripSandboxPaths({
30114
31041
  sandbox: overrideSandbox,
31042
+ refusals: [
31043
+ CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
31044
+ CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
31045
+ ...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
31046
+ ],
30115
31047
  relativeFilePath: paths.relativeFilePath,
30116
31048
  logger
30117
31049
  });
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,
31050
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
31051
+ sandbox: honorableSandbox,
30129
31052
  relativeFilePath: paths.relativeFilePath,
30130
31053
  logger
30131
31054
  });
31055
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
30132
31056
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
30133
31057
  }
30134
31058
  const overrideTopLevel = {};
@@ -30138,13 +31062,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30138
31062
  if (value === void 0) continue;
30139
31063
  overrideTopLevel[key] = value;
30140
31064
  }
30141
- const scopedTopLevel = stripUnhonoredTopLevelKeys({
31065
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
30142
31066
  overrides: overrideTopLevel,
30143
31067
  global,
30144
31068
  relativeFilePath: paths.relativeFilePath,
30145
31069
  logger
30146
31070
  });
31071
+ trustAffecting.push(...trustAffectingTopLevel);
30147
31072
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
31073
+ warnOnTrustAffectingEntries({
31074
+ entries: trustAffecting,
31075
+ relativeFilePath: paths.relativeFilePath,
31076
+ logger
31077
+ });
30148
31078
  const managedToolNames = managedClaudeToolNames(config);
30149
31079
  const merged = applyPermissions({
30150
31080
  settings,
@@ -35122,11 +36052,8 @@ const TOOL_KEY_TO_CATEGORY = {
35122
36052
  updateConfluencePage: "edit"
35123
36053
  };
35124
36054
  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
- ];
36055
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
36056
+ const MANAGED_BASH_KEYS = ["default", "commands"];
35130
36057
  /**
35131
36058
  * Permissions adapter for Rovo Dev CLI.
35132
36059
  *
@@ -35262,6 +36189,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35262
36189
  }
35263
36190
  };
35264
36191
  /**
36192
+ * Report a `toolPermissions.bash.runInSandbox: false` that this generate is
36193
+ * about to carry through.
36194
+ *
36195
+ * The unmanaged `bash` siblings survive untouched, which for this one means a
36196
+ * generate is not the reset a user might read it as: every command the agent
36197
+ * runs stays outside the sandbox. Rulesync never authors the key and will not
36198
+ * start owning it, but a setting that persists on a committed file and loosens
36199
+ * containment should not do so without saying anything — least of all on the
36200
+ * path where the user just tightened `.rulesync/permissions.*`.
36201
+ */
36202
+ function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
36203
+ const existingBash = existingToolPermissions.bash;
36204
+ if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
36205
+ 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.`);
36206
+ }
36207
+ /**
35265
36208
  * Resolve the `toolPermissions` block to write, merging the generated levels
35266
36209
  * over the existing file. Every other top-level key of `config.yml` is the
35267
36210
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -35269,6 +36212,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35269
36212
  */
35270
36213
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
35271
36214
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
36215
+ warnAboutPreservedSandboxOptOut({
36216
+ existingToolPermissions,
36217
+ filePath,
36218
+ logger
36219
+ });
35272
36220
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
35273
36221
  if (!isRecord$1(existing)) return;
35274
36222
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -35277,9 +36225,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35277
36225
  }
35278
36226
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
35279
36227
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
36228
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
36229
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
35280
36230
  warnAboutDroppedOwnedKeys({
35281
36231
  existingToolPermissions,
35282
36232
  existingTools,
36233
+ existingBash,
35283
36234
  generated,
35284
36235
  filePath,
35285
36236
  logger
@@ -35289,15 +36240,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35289
36240
  delete existingToolPermissions[toolKey];
35290
36241
  delete existingTools[toolKey];
35291
36242
  }
36243
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
35292
36244
  const tools = {
35293
36245
  ...existingTools,
35294
36246
  ...generated.tools
35295
36247
  };
36248
+ const bash = {
36249
+ ...existingBash,
36250
+ ...generated.bash
36251
+ };
35296
36252
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
36253
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
35297
36254
  return {
35298
36255
  ...existingToolPermissions,
35299
36256
  ...generated,
35300
- ...Object.keys(tools).length > 0 ? { tools } : {}
36257
+ ...Object.keys(tools).length > 0 ? { tools } : {},
36258
+ ...Object.keys(bash).length > 0 ? { bash } : {}
35301
36259
  };
35302
36260
  }
35303
36261
  /**
@@ -35306,9 +36264,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35306
36264
  * `/directories` and by an "always allow" answer to a prompt — so their removal
35307
36265
  * must not be silent.
35308
36266
  */
35309
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
36267
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
35310
36268
  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}`)];
36269
+ const newBash = generated.bash ?? {};
36270
+ const droppedKeys = [
36271
+ ...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
36272
+ ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
36273
+ ...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
36274
+ ];
35312
36275
  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
36276
  }
35314
36277
  /**
@@ -37416,10 +38379,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
37416
38379
  var PermissionsProcessor = class extends FeatureProcessor {
37417
38380
  toolTarget;
37418
38381
  global;
37419
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
38382
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
37420
38383
  super({
37421
38384
  outputRoot,
37422
- inputRoot,
38385
+ inputRoots,
37423
38386
  dryRun,
37424
38387
  logger
37425
38388
  });
@@ -37429,9 +38392,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
37429
38392
  this.global = global;
37430
38393
  }
37431
38394
  async loadRulesyncFiles() {
38395
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
38396
+ const sourceTree = await pickLastRootWithFile({
38397
+ inputRoots: this.inputRoots,
38398
+ relativePaths,
38399
+ logger: this.logger,
38400
+ artifactName: "The permissions file"
38401
+ }) ?? this.inputRoots[0];
37432
38402
  try {
37433
38403
  return [await RulesyncPermissions.fromFile({
37434
- outputRoot: this.inputRoot,
38404
+ outputRoot: dirname(sourceTree),
38405
+ relativeDirPath: basename(sourceTree),
37435
38406
  validate: true
37436
38407
  })];
37437
38408
  } catch (error) {
@@ -37855,13 +38826,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37855
38826
  //#region src/types/dir-feature-processor.ts
37856
38827
  var DirFeatureProcessor = class {
37857
38828
  outputRoot;
37858
- inputRoot;
38829
+ /**
38830
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
38831
+ * is a source tree itself — the directory that directly contains
38832
+ * feature subdirectories (`rules/`, `skills/`, …) and single-file
38833
+ * features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
38834
+ * when two trees supply the same relative path. Defaults to
38835
+ * `[join(process.cwd(), ".rulesync")]`.
38836
+ *
38837
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
38838
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
38839
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
38840
+ * reaches a processor.
38841
+ */
38842
+ inputRoots;
37859
38843
  dryRun;
37860
38844
  avoidBlockScalars;
37861
38845
  logger;
37862
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
38846
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37863
38847
  this.outputRoot = outputRoot;
37864
- this.inputRoot = inputRoot;
38848
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
37865
38849
  this.dryRun = dryRun;
37866
38850
  this.avoidBlockScalars = avoidBlockScalars;
37867
38851
  this.logger = logger;
@@ -40120,9 +41104,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
40120
41104
  * slug is owned by the commands feature: it must not be imported as a
40121
41105
  * skill nor deleted as an orphan skill.
40122
41106
  */
40123
- static async isDirOwned({ dirName, inputRoot }) {
41107
+ static async isDirOwned({ dirName, inputRoots }) {
40124
41108
  return !await rulesyncCommandSlugExists({
40125
- inputRoot,
41109
+ inputRoots,
40126
41110
  dirName
40127
41111
  });
40128
41112
  }
@@ -40172,7 +41156,11 @@ const FactorydroidSkillFrontmatterSchema = z.looseObject({
40172
41156
  "user-invocable": z.optional(z.boolean()),
40173
41157
  "disable-model-invocation": z.optional(z.boolean()),
40174
41158
  enabled: z.optional(z.boolean()),
40175
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
41159
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
41160
+ license: z.optional(z.unknown()),
41161
+ compatibility: z.optional(z.unknown()),
41162
+ metadata: z.optional(z.unknown()),
41163
+ version: z.optional(z.unknown())
40176
41164
  });
40177
41165
  /**
40178
41166
  * Represents a Factory Droid skill directory.
@@ -40225,16 +41213,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40225
41213
  };
40226
41214
  }
40227
41215
  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
- };
41216
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
40235
41217
  const rulesyncFrontmatter = {
40236
- name: frontmatter.name,
40237
- description: frontmatter.description,
41218
+ name,
41219
+ description,
40238
41220
  targets: ["*"],
40239
41221
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
40240
41222
  };
@@ -40261,13 +41243,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40261
41243
  rootFrontmatter: rulesyncFrontmatter,
40262
41244
  section: factorydroidSection
40263
41245
  });
41246
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
40264
41247
  const factorydroidFrontmatter = {
40265
41248
  name: rulesyncFrontmatter.name,
40266
41249
  description: rulesyncFrontmatter.description,
41250
+ ...section,
40267
41251
  ...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"] }
41252
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
40271
41253
  };
40272
41254
  return new FactorydroidSkill({
40273
41255
  outputRoot,
@@ -40679,7 +41661,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40679
41661
  }
40680
41662
  }
40681
41663
  static getSettablePaths(_options) {
40682
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
41664
+ return {
41665
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
41666
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
41667
+ };
40683
41668
  }
40684
41669
  getFrontmatter() {
40685
41670
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -42841,9 +43826,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42841
43826
  * slug is owned by the commands feature: it must not be imported as a
42842
43827
  * skill nor deleted as an orphan skill.
42843
43828
  */
42844
- static async isDirOwned({ dirName, inputRoot }) {
43829
+ static async isDirOwned({ dirName, inputRoots }) {
42845
43830
  return !await rulesyncCommandSlugExists({
42846
- inputRoot,
43831
+ inputRoots,
42847
43832
  dirName
42848
43833
  });
42849
43834
  }
@@ -43401,10 +44386,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43401
44386
  toolTarget;
43402
44387
  global;
43403
44388
  getFactory;
43404
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
44389
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
43405
44390
  super({
43406
44391
  outputRoot,
43407
- inputRoot,
44392
+ inputRoots,
43408
44393
  dryRun,
43409
44394
  avoidBlockScalars: toolTarget === "cursor",
43410
44395
  logger
@@ -43442,39 +44427,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43442
44427
  return rulesyncSkills;
43443
44428
  }
43444
44429
  /**
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.
44430
+ * Load rulesync skill directories from a single source-tree's `skills/`
44431
+ * (and `skills/.curated/`) subtree. `sourceTree` is the source tree
44432
+ * itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
44433
+ * local skills take precedence over curated skills with the same name.
43449
44434
  */
43450
- async loadRulesyncDirs() {
43451
- const localDirNames = [...await getLocalSkillDirNames(this.inputRoot)];
44435
+ async loadRulesyncDirsForRoot(sourceTree) {
44436
+ const treeParent = dirname(sourceTree);
44437
+ const treeName = basename(sourceTree);
44438
+ const treeSkillsDirPath = join(treeName, SKILLS_FEATURE_SUBDIR);
44439
+ const treeCuratedSkillsDirPath = join(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
44440
+ const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
43452
44441
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
43453
- outputRoot: this.inputRoot,
44442
+ outputRoot: treeParent,
44443
+ relativeDirPath: treeSkillsDirPath,
43454
44444
  dirName,
43455
44445
  global: this.global
43456
44446
  })));
43457
- const localSkillNames = new Set(localDirNames);
43458
- const curatedDirPath = join(this.inputRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
44447
+ const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
44448
+ const curatedDirPath = join(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
43459
44449
  let curatedSkills = [];
43460
44450
  if (await directoryExists(curatedDirPath)) {
43461
44451
  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;
44452
+ const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
44453
+ if (spellings === void 0) return true;
44454
+ if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
44455
+ else this.logger.warn(formatCuratedCaseCollisionWarning({
44456
+ artifactKind: "skill",
44457
+ entryNoun: "skill",
44458
+ treeDirPath: treeSkillsDirPath,
44459
+ curatedSpelling: name,
44460
+ localSpellings: spellings
44461
+ }));
44462
+ return false;
43467
44463
  });
43468
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
43469
44464
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
43470
- outputRoot: this.inputRoot,
43471
- relativeDirPath: curatedRelativeDirPath,
44465
+ outputRoot: treeParent,
44466
+ relativeDirPath: treeCuratedSkillsDirPath,
43472
44467
  dirName,
43473
44468
  global: this.global
43474
44469
  })));
43475
44470
  }
43476
- const allSkills = [...localSkills, ...curatedSkills];
43477
- this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills (${localSkills.length} local, ${curatedSkills.length} curated)`);
44471
+ return [...localSkills, ...curatedSkills];
44472
+ }
44473
+ /**
44474
+ * Implementation of abstract method from DirFeatureProcessor.
44475
+ *
44476
+ * Load and parse rulesync skill directories from every configured input
44477
+ * root's `.rulesync/skills/` tree (each root also honours its own
44478
+ * `.curated/` subdirectory). When two roots supply a skill with the same
44479
+ * directory name, the later root's skill replaces the earlier root's copy
44480
+ * atomically (companion files included) — an overlay always ships a whole
44481
+ * skill directory, never a partial patch.
44482
+ */
44483
+ async loadRulesyncDirs() {
44484
+ const allSkills = mergeByCaseInsensitiveIdentity({
44485
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
44486
+ identity: (skill) => skill.getDirName(),
44487
+ artifactName: "skill",
44488
+ logger: this.logger
44489
+ });
44490
+ this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
43478
44491
  return allSkills;
43479
44492
  }
43480
44493
  /**
@@ -43490,7 +44503,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43490
44503
  }) : [];
43491
44504
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
43492
44505
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
43493
- const seenSkillNames = /* @__PURE__ */ new Set();
44506
+ const claimedSkillNames = new ClaimedIdentities();
44507
+ const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
44508
+ const skillName = skill.getImportIdentity();
44509
+ const claimed = claimedSkillNames.claim({
44510
+ identity: skillName,
44511
+ source: relativeDirPath
44512
+ });
44513
+ if (claimed === null) return true;
44514
+ 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.`);
44515
+ return false;
44516
+ };
43494
44517
  const toolSkills = [];
43495
44518
  for (const root of roots) {
43496
44519
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -43506,54 +44529,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43506
44529
  outputRoot: rootOutputRoot,
43507
44530
  relativeDirPath,
43508
44531
  dirName,
43509
- inputRoot: this.inputRoot
44532
+ inputRoots: this.inputRoots
43510
44533
  })) continue;
43511
44534
  ownedDirNames.push(dirName);
43512
44535
  }
43513
44536
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
44537
+ const sourcePath = join(relativeDirPath, dirName);
43514
44538
  try {
43515
- return await factory.class.fromDir({
43516
- outputRoot: rootOutputRoot,
43517
- relativeDirPath,
43518
- dirName,
43519
- global: this.global
43520
- });
44539
+ return {
44540
+ skill: await factory.class.fromDir({
44541
+ outputRoot: rootOutputRoot,
44542
+ relativeDirPath,
44543
+ dirName,
44544
+ global: this.global
44545
+ }),
44546
+ sourcePath
44547
+ };
43521
44548
  } catch (error) {
43522
44549
  if (!isLenientRoot) throw error;
43523
- this.logger.warn(`Skipping ${join(relativeDirPath, dirName)}: ${formatError(error)}`);
44550
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43524
44551
  return null;
43525
44552
  }
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
- }
44553
+ }))).filter((loaded) => loaded !== null);
44554
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
44555
+ skill,
44556
+ relativeDirPath,
44557
+ sourcePath
44558
+ })) toolSkills.push(skill);
43533
44559
  if (!factory.class.fromFlatFile) continue;
43534
44560
  const fromFlatFile = factory.class.fromFlatFile;
43535
44561
  const directoryStems = new Set(ownedDirNames);
43536
44562
  const flatFilePaths = (await findFilesByGlobs(join(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has(basename(filePath, ".md")));
43537
44563
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
44564
+ const sourcePath = join(relativeDirPath, basename(filePath));
43538
44565
  try {
43539
- return await fromFlatFile({
43540
- outputRoot: rootOutputRoot,
43541
- relativeDirPath,
43542
- relativeFilePath: basename(filePath),
43543
- global: this.global
43544
- });
44566
+ return {
44567
+ skill: await fromFlatFile({
44568
+ outputRoot: rootOutputRoot,
44569
+ relativeDirPath,
44570
+ relativeFilePath: basename(filePath),
44571
+ global: this.global
44572
+ }),
44573
+ sourcePath
44574
+ };
43545
44575
  } catch (error) {
43546
44576
  if (!isLenientRoot) throw error;
43547
- this.logger.warn(`Skipping ${join(relativeDirPath, basename(filePath))}: ${formatError(error)}`);
44577
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43548
44578
  return null;
43549
44579
  }
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
- }
44580
+ }))).filter((loaded) => loaded !== null);
44581
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
44582
+ skill,
44583
+ relativeDirPath,
44584
+ sourcePath
44585
+ })) toolSkills.push(skill);
43557
44586
  }
43558
44587
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43559
44588
  return toolSkills;
@@ -43583,7 +44612,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43583
44612
  outputRoot: this.outputRoot,
43584
44613
  relativeDirPath: root,
43585
44614
  dirName,
43586
- inputRoot: this.inputRoot
44615
+ inputRoots: this.inputRoots
43587
44616
  })) continue;
43588
44617
  const toolSkill = factory.class.forDeletion({
43589
44618
  outputRoot: this.outputRoot,
@@ -46341,6 +47370,7 @@ const JunieSubagentFrontmatterSchema = z.looseObject({
46341
47370
  disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])),
46342
47371
  mcpServers: z.optional(z.union([z.string(), z.array(z.string())])),
46343
47372
  model: z.optional(z.string()),
47373
+ permissionMode: z.optional(z.string()),
46344
47374
  reasoningLevel: z.optional(z.string()),
46345
47375
  maxTurns: z.optional(z.number()),
46346
47376
  skills: z.optional(z.union([z.string(), z.array(z.string())])),
@@ -48321,14 +49351,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
48321
49351
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
48322
49352
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
48323
49353
  });
49354
+ /**
49355
+ * Stands in for a discovery root when a subagent came from a tool's own config
49356
+ * file rather than a directory (see `loadAdditionalImportFiles`). The angle
49357
+ * brackets keep it from ever matching a real relative directory path.
49358
+ */
49359
+ const INLINE_SOURCE = "<inline>";
49360
+ /**
49361
+ * The single "root" of the post-conversion output guard, which de-duplicates
49362
+ * `.rulesync/subagents/` paths rather than discovery roots.
49363
+ */
49364
+ const OUTPUT_SOURCE = "<output>";
48324
49365
  var SubagentsProcessor = class extends FeatureProcessor {
48325
49366
  toolTarget;
48326
49367
  global;
48327
49368
  getFactory;
48328
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
49369
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
48329
49370
  super({
48330
49371
  outputRoot,
48331
- inputRoot,
49372
+ inputRoots,
48332
49373
  dryRun,
48333
49374
  logger
48334
49375
  });
@@ -48374,24 +49415,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
48374
49415
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
48375
49416
  }
48376
49417
  const uniqueRulesyncSubagents = [];
48377
- const seenOutputPaths = /* @__PURE__ */ new Set();
49418
+ const claimedOutputPaths = new ClaimedIdentities();
48378
49419
  for (const rulesyncSubagent of rulesyncSubagents) {
48379
49420
  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.`);
49421
+ const claimed = claimedOutputPaths.claim({
49422
+ identity: outputPath,
49423
+ source: OUTPUT_SOURCE
49424
+ });
49425
+ if (claimed !== null) {
49426
+ 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
49427
  continue;
48383
49428
  }
48384
- seenOutputPaths.add(outputPath);
48385
49429
  uniqueRulesyncSubagents.push(rulesyncSubagent);
48386
49430
  }
48387
49431
  return uniqueRulesyncSubagents;
48388
49432
  }
48389
49433
  /**
48390
- * Implementation of abstract method from Processor
48391
- * Load and parse rulesync subagent files from .rulesync/subagents/ directory
49434
+ * Load subagent files from a single source-tree's `subagents/` subtree.
49435
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
49436
+ * `/repo/.rulesync.local`).
48392
49437
  */
48393
- async loadRulesyncFiles() {
48394
- const subagentsDir = join(this.inputRoot, RulesyncSubagent.getSettablePaths().relativeDirPath);
49438
+ async loadRulesyncFilesForRoot(sourceTree) {
49439
+ const treeParent = dirname(sourceTree);
49440
+ const treeName = basename(sourceTree);
49441
+ const treeSubagentsDirPath = join(treeName, SUBAGENTS_FEATURE_SUBDIR);
49442
+ const subagentsDir = join(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
48395
49443
  if (!await directoryExists(subagentsDir)) {
48396
49444
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
48397
49445
  return [];
@@ -48407,7 +49455,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
48407
49455
  const filepath = join(subagentsDir, mdFile);
48408
49456
  try {
48409
49457
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
48410
- outputRoot: this.inputRoot,
49458
+ outputRoot: treeParent,
49459
+ relativeDirPath: treeSubagentsDirPath,
48411
49460
  relativeFilePath: mdFile,
48412
49461
  validate: true
48413
49462
  });
@@ -48418,8 +49467,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
48418
49467
  continue;
48419
49468
  }
48420
49469
  }
49470
+ return rulesyncSubagents;
49471
+ }
49472
+ /**
49473
+ * Implementation of abstract method from Processor
49474
+ * Load and parse rulesync subagent files from every configured input root's
49475
+ * `.rulesync/subagents/` directory, merging by relative file path so a
49476
+ * subagent with the same target path from a later root replaces the
49477
+ * earlier root's copy.
49478
+ */
49479
+ async loadRulesyncFiles() {
49480
+ const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
49481
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
49482
+ identity: (subagent) => subagent.getRelativeFilePath(),
49483
+ artifactName: "subagent",
49484
+ logger: this.logger
49485
+ });
48421
49486
  if (rulesyncSubagents.length === 0) {
48422
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
49487
+ this.logger.debug(`No valid subagents found`);
48423
49488
  return [];
48424
49489
  }
48425
49490
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -48434,7 +49499,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
48434
49499
  const paths = factory.class.getSettablePaths({ global: this.global });
48435
49500
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
48436
49501
  const toolSubagents = [];
48437
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
49502
+ const claimedRelativeFilePaths = new ClaimedIdentities();
48438
49503
  for (const root of roots) {
48439
49504
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
48440
49505
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -48473,37 +49538,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
48473
49538
  relativeFilePath: toRelativeFilePath(path),
48474
49539
  global: this.global
48475
49540
  })));
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);
49541
+ toolSubagents.push(...this.claimStandaloneSubagents({
49542
+ loaded,
49543
+ dirPath,
49544
+ claimedRelativeFilePaths
49545
+ }));
48487
49546
  }
48488
49547
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
48489
49548
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
48490
49549
  outputRoot: this.outputRoot,
48491
49550
  global: this.global
48492
49551
  });
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
- }
49552
+ toolSubagents.push(...this.claimInlineSubagents({
49553
+ additionalSubagents,
49554
+ claimedRelativeFilePaths
49555
+ }));
48502
49556
  }
48503
49557
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
48504
49558
  return toolSubagents;
48505
49559
  }
48506
49560
  /**
49561
+ * Keeps the subagents from one discovery root whose import identity is still
49562
+ * unclaimed, warning about each copy that loses. Split out of
49563
+ * `loadToolFiles` so the two de-duplication passes stay readable side by
49564
+ * side (and so that method stays within the linter's complexity budget).
49565
+ *
49566
+ * When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
49567
+ * plus `.agents/`), two roots can hold a subagent with the same relative
49568
+ * path. Downstream conversion keys by that path, so a later one would
49569
+ * silently overwrite an earlier one. Warn instead of failing, keeping the
49570
+ * earlier (higher-precedence) root's file.
49571
+ */
49572
+ claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
49573
+ const deduped = [];
49574
+ for (const subagent of loaded) {
49575
+ const key = subagent.getImportIdentity();
49576
+ const claimed = claimedRelativeFilePaths.claim({
49577
+ identity: key,
49578
+ source: dirPath
49579
+ });
49580
+ if (claimed === null) {
49581
+ deduped.push(subagent);
49582
+ continue;
49583
+ }
49584
+ const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
49585
+ 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.`);
49586
+ }
49587
+ return deduped;
49588
+ }
49589
+ /**
49590
+ * The same claim-or-warn pass for subagents defined inline in a tool's own
49591
+ * config file (see `loadAdditionalImportFiles`), which are scanned after
49592
+ * every standalone file so a Markdown file of the same name wins.
49593
+ */
49594
+ claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
49595
+ const deduped = [];
49596
+ for (const subagent of additionalSubagents) {
49597
+ const key = subagent.getImportIdentity();
49598
+ const claimed = claimedRelativeFilePaths.claim({
49599
+ identity: key,
49600
+ source: INLINE_SOURCE
49601
+ });
49602
+ if (claimed === null) {
49603
+ deduped.push(subagent);
49604
+ continue;
49605
+ }
49606
+ const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
49607
+ 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.`);
49608
+ }
49609
+ return deduped;
49610
+ }
49611
+ /**
48507
49612
  * Implementation of abstract method from FeatureProcessor
48508
49613
  * Return the tool targets that this processor supports
48509
49614
  */
@@ -51433,17 +52538,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
51433
52538
  * Rule generator for JetBrains Junie AI coding agent
51434
52539
  *
51435
52540
  * 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
52541
+ * resolves project guidelines in this order: `.junie/AGENTS.md` → root
52542
+ * `AGENTS.md` **combined with `.junie/playbook.md` and every
52543
+ * `.junie/rules/*.md`** legacy `.junie/guidelines.md` / `.junie/guidelines/`.
52544
+ * The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
52545
+ * is present: that file "is used exclusively and no other guidelines files are
52546
+ * combined with it". So emitting `.junie/rules/*.md` next to the root file
52547
+ * rulesync writes would produce files Junie never reads, and moving the root
52548
+ * output to project-root `AGENTS.md` would both change every existing output
52549
+ * path and collide with the `agentsmd` target. Non-root rules therefore stay
52550
+ * folded into the single root `.junie/AGENTS.md` by the RulesProcessor
52551
+ * (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
52552
+ * fold is lossless, since Junie loads that one file in full. The original
52553
+ * rationale for this shape was recorded in issue #2211 and re-confirmed
52554
+ * against the 2026-08-21 docs revision in issue #2728. The legacy
51443
52555
  * `.junie/guidelines.md` is still accepted as an import fallback, but
51444
52556
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
51445
52557
  * without frontmatter requirements.
51446
52558
  *
52559
+ * The multi-file branch is still read on **import**, though: a repo that
52560
+ * authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
52561
+ * layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
52562
+ * first `rulesync import` finds — declares those paths as `importOnlyRoots`
52563
+ * and imports them as non-root rules. They are never written back there:
52564
+ * generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
52565
+ * folded root file carries the same content.
52566
+ *
51447
52567
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
51448
52568
  * these user-scope guidelines with the project guidelines (both are included
51449
52569
  * and marked clearly).
@@ -51465,6 +52585,14 @@ var JunieRule = class JunieRule extends ToolRule {
51465
52585
  alternativeRoots: [{
51466
52586
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
51467
52587
  relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
52588
+ }],
52589
+ importOnlyRoots: [{
52590
+ relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
52591
+ onlyWhenRootAbsent: true
52592
+ }, {
52593
+ relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
52594
+ relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
52595
+ onlyWhenRootAbsent: true
51468
52596
  }]
51469
52597
  };
51470
52598
  }
@@ -51475,7 +52603,7 @@ var JunieRule = class JunieRule extends ToolRule {
51475
52603
  static isRootRelativeFilePath(relativeFilePath) {
51476
52604
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
51477
52605
  }
51478
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
52606
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
51479
52607
  if (global) {
51480
52608
  const paths = this.getSettablePaths({ global: true });
51481
52609
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -51489,7 +52617,8 @@ var JunieRule = class JunieRule extends ToolRule {
51489
52617
  root: true
51490
52618
  });
51491
52619
  }
51492
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
52620
+ const settablePaths = this.getSettablePaths();
52621
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
51493
52622
  const relativePath = join(relativeDirPath, relativeFilePath);
51494
52623
  const fileContent = await readFileContent(join(outputRoot, relativePath));
51495
52624
  return new JunieRule({
@@ -51498,7 +52627,7 @@ var JunieRule = class JunieRule extends ToolRule {
51498
52627
  relativeFilePath,
51499
52628
  fileContent,
51500
52629
  validate,
51501
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
52630
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
51502
52631
  });
51503
52632
  }
51504
52633
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53850,10 +54979,23 @@ const defaultGetFactory = (target) => {
53850
54979
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53851
54980
  return factory;
53852
54981
  };
53853
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53854
- const primaryFilePaths = await findFilesByGlobs(primaryGlob);
54982
+ /**
54983
+ * How many skipped import-only paths a single warning names before it
54984
+ * summarizes the rest. Keeps one line readable when a rules directory holds
54985
+ * dozens of files.
54986
+ */
54987
+ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
54988
+ /**
54989
+ * Fall back to a tool's legacy roots when its primary root file is absent.
54990
+ *
54991
+ * The primary hits are passed in rather than globbed here, so that callers
54992
+ * which need "the root file Rulesync generates" — rather than "whatever root
54993
+ * the tool will read" — can keep the two apart. A legacy root is a file
54994
+ * Rulesync reads but never writes, and the difference matters to them.
54995
+ */
54996
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
53855
54997
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53856
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
54998
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53857
54999
  return [];
53858
55000
  };
53859
55001
  var RulesProcessor = class extends FeatureProcessor {
@@ -53865,10 +55007,10 @@ var RulesProcessor = class extends FeatureProcessor {
53865
55007
  getFactory;
53866
55008
  skills;
53867
55009
  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 }) {
55010
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
53869
55011
  super({
53870
55012
  outputRoot,
53871
- inputRoot,
55013
+ inputRoots,
53872
55014
  dryRun,
53873
55015
  logger
53874
55016
  });
@@ -54243,25 +55385,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
54243
55385
  claimedBy.set(target.toLowerCase(), source);
54244
55386
  continue;
54245
55387
  }
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.`);
55388
+ 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
55389
  }
54248
55390
  return rulesyncRules;
54249
55391
  }
54250
55392
  /**
54251
- * Implementation of abstract method from FeatureProcessor
54252
- * Load and parse rulesync rule files from .rulesync/rules/ directory
55393
+ * Load rulesync rule files from a single source-tree's `rules/` (and
55394
+ * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
55395
+ * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
55396
+ *
55397
+ * Intra-tree behavior — the local-wins-over-curated rule and the
55398
+ * case-insensitive collision handling — is preserved from the previous
55399
+ * single-root implementation. See `loadRulesyncFiles` for how the
55400
+ * per-root results are combined into the effective set.
54253
55401
  */
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);
55402
+ async loadRulesyncFilesForRoot(sourceTree) {
55403
+ const treeParent = dirname(sourceTree);
55404
+ const treeName = basename(sourceTree);
55405
+ const treeRulesDirPath = join(treeName, RULES_FEATURE_SUBDIR);
55406
+ const rulesyncOutputRoot = join(sourceTree, RULES_FEATURE_SUBDIR);
55407
+ const curatedOutputRoot = join(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
54257
55408
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs(join(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs(join(curatedOutputRoot, "**", "*.md"))]);
54258
55409
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
54259
55410
  const localFiles = files.filter((file) => !relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`));
54260
- const localRelativePaths = new Set(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
55411
+ const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
54261
55412
  const curatedFiles = files.filter((file) => relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`)).map((file) => ({
54262
55413
  file,
54263
55414
  relativeFilePath: relative(curatedOutputRoot, file)
54264
- })).filter(({ relativeFilePath }) => !localRelativePaths.has(relativeFilePath));
55415
+ })).filter(({ relativeFilePath }) => {
55416
+ const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
55417
+ if (spellings === void 0) return true;
55418
+ if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
55419
+ artifactKind: "rule",
55420
+ entryNoun: "file",
55421
+ treeDirPath: treeRulesDirPath,
55422
+ curatedSpelling: join(".curated", relativeFilePath),
55423
+ localSpellings: spellings
55424
+ }));
55425
+ return false;
55426
+ });
54265
55427
  const selectedFiles = [...localFiles.map((file) => ({
54266
55428
  file,
54267
55429
  sourceRelativeFilePath: relative(rulesyncOutputRoot, file),
@@ -54271,25 +55433,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
54271
55433
  sourceRelativeFilePath: join(".curated", relativeFilePath),
54272
55434
  relativeFilePath
54273
55435
  }))];
54274
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
54275
- const rulesyncRules = await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
55436
+ this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
55437
+ return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
54276
55438
  checkPathTraversal({
54277
55439
  relativePath: sourceRelativeFilePath,
54278
55440
  intendedRootDir: rulesyncOutputRoot
54279
55441
  });
54280
55442
  const rule = await RulesyncRule.fromFile({
54281
- outputRoot: this.inputRoot,
55443
+ outputRoot: treeParent,
55444
+ relativeDirPath: treeRulesDirPath,
54282
55445
  relativeFilePath: sourceRelativeFilePath
54283
55446
  });
54284
55447
  if (sourceRelativeFilePath === relativeFilePath) return rule;
54285
55448
  return new RulesyncRule({
54286
- outputRoot: this.inputRoot,
54287
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
55449
+ outputRoot: treeParent,
55450
+ relativeDirPath: treeRulesDirPath,
54288
55451
  relativeFilePath,
54289
55452
  frontmatter: rule.getFrontmatter(),
54290
55453
  body: rule.getBody()
54291
55454
  });
54292
55455
  }));
55456
+ }
55457
+ /**
55458
+ * Implementation of abstract method from FeatureProcessor
55459
+ * Load and parse rulesync rule files from every configured input root's
55460
+ * `.rulesync/rules/` directory, merging by relative path so that a rule
55461
+ * with the same target path from a later root replaces the earlier root's
55462
+ * copy (case-insensitive, matching the intra-root collision handling).
55463
+ */
55464
+ async loadRulesyncFiles() {
55465
+ const rulesyncRules = mergeByCaseInsensitiveIdentity({
55466
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
55467
+ identity: (rule) => rule.getRelativeFilePath(),
55468
+ artifactName: "rule",
55469
+ logger: this.logger
55470
+ });
54293
55471
  const factory = this.getFactory(this.toolTarget);
54294
55472
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
54295
55473
  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 +55561,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
54383
55561
  });
54384
55562
  }).filter((rule) => rule.isDeletable());
54385
55563
  };
55564
+ /**
55565
+ * Import counterpart of {@link buildDeletionRulesFromPaths} for the
55566
+ * root-shaped scans (root, legacy roots, and read-only roots), whose
55567
+ * paths all sit at a directory Rulesync resolves from the file itself.
55568
+ */
55569
+ const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
55570
+ const relativeDirPath = resolveRelativeDirPath(filePath);
55571
+ checkPathTraversal({
55572
+ relativePath: relativeDirPath,
55573
+ intendedRootDir: this.outputRoot
55574
+ });
55575
+ return factory.class.fromFile({
55576
+ outputRoot: this.outputRoot,
55577
+ relativeDirPath,
55578
+ relativeFilePath: basename(filePath),
55579
+ global: this.global
55580
+ });
55581
+ }));
55582
+ /**
55583
+ * The tool's own root file, as opposed to whichever root
55584
+ * `rootToolRules` ends up resolving. A legacy root reached through
55585
+ * `alternativeRoots` is deliberately not counted here: it is a
55586
+ * hand-authored file Rulesync never writes, so it has folded nothing in,
55587
+ * and in Junie's resolution order it ranks *below* the multi-file layout
55588
+ * that `importOnlyRoots` describes. Gating those roots on it would drop
55589
+ * exactly the files the tool is really reading.
55590
+ *
55591
+ * Resolved once, up front, so the two blocks that need it do not depend
55592
+ * on each other's evaluation order.
55593
+ */
55594
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
54386
55595
  const rootToolRules = await (async () => {
54387
55596
  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));
55597
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
54389
55598
  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
- }));
55599
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
54403
55600
  })();
54404
55601
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
54405
55602
  const localRootToolRules = await (async () => {
@@ -54411,7 +55608,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54411
55608
  fileName
54412
55609
  }));
54413
55610
  if (!settablePaths.root) return [];
54414
- return await findFilesWithFallback(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
55611
+ return await findFilesWithFallback(await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
54415
55612
  })();
54416
55613
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
54417
55614
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -54487,6 +55684,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
54487
55684
  }));
54488
55685
  })();
54489
55686
  this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
55687
+ const importOnlyToolRules = await (async () => {
55688
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
55689
+ if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
55690
+ const rootFilePath = primaryRootFilePaths[0];
55691
+ const scannedPaths = [];
55692
+ const skippedPaths = [];
55693
+ for (const importOnlyRoot of importOnlyRoots) {
55694
+ const matchedPaths = await findFilesByGlobs(join(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
55695
+ if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
55696
+ skippedPaths.push(...matchedPaths);
55697
+ continue;
55698
+ }
55699
+ scannedPaths.push(...matchedPaths);
55700
+ }
55701
+ if (skippedPaths.length > 0 && rootFilePath !== void 0) {
55702
+ const skippedNames = skippedPaths.map((filePath) => stripControlCharacters(relative(this.outputRoot, filePath)));
55703
+ const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
55704
+ const remainingCount = skippedNames.length - listedNames.length;
55705
+ 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.`);
55706
+ }
55707
+ return await buildImportRulesFromPaths(scannedPaths);
55708
+ })();
55709
+ this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
54490
55710
  const nonRootToolRules = await (async () => {
54491
55711
  if (!settablePaths.nonRoot) return [];
54492
55712
  const nonRootOutputRoot = join(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54519,6 +55739,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54519
55739
  })();
54520
55740
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54521
55741
  return [
55742
+ ...importOnlyToolRules,
54522
55743
  ...rootToolRules,
54523
55744
  ...localRootToolRules,
54524
55745
  ...rootMirrorDeletionRules,
@@ -55326,14 +56547,53 @@ function warnUnsupportedTargets(params) {
55326
56547
  }
55327
56548
  }
55328
56549
  /**
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.
56550
+ * Inspect every configured input-root path. The first entry is the required
56551
+ * base source tree; later entries are optional overlays and may be absent.
56552
+ * Each existing entry is a rulesync source tree itself (the directory that
56553
+ * directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
56554
+ * directories are valid because delete and check workflows still need to
56555
+ * inspect generated outputs.
55334
56556
  */
55335
- async function checkRulesyncDirExists(params) {
55336
- return fileExists(join(params.inputRoot, RULESYNC_RELATIVE_DIR_PATH));
56557
+ async function inspectInputRoots(inputRoots) {
56558
+ const existing = [];
56559
+ const missing = [];
56560
+ const invalidOverlays = [];
56561
+ const nonDirectories = /* @__PURE__ */ new Set();
56562
+ for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
56563
+ else {
56564
+ missing.push(root);
56565
+ if (await fileExists(root)) {
56566
+ nonDirectories.add(root);
56567
+ if (index > 0) invalidOverlays.push(root);
56568
+ }
56569
+ }
56570
+ const primaryRoot = inputRoots[0];
56571
+ const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
56572
+ if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
56573
+ const invalidOverlay = invalidOverlays[0];
56574
+ return {
56575
+ existing,
56576
+ missing,
56577
+ message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
56578
+ };
56579
+ }
56580
+ const defaultRoot = join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
56581
+ if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
56582
+ existing,
56583
+ missing,
56584
+ message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
56585
+ };
56586
+ const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
56587
+ if (nonDirectories.has(primaryRoot)) return {
56588
+ existing,
56589
+ missing,
56590
+ message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
56591
+ };
56592
+ return {
56593
+ existing,
56594
+ missing,
56595
+ message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
56596
+ };
55337
56597
  }
55338
56598
  function dependsOnReachable(byId, from, target) {
55339
56599
  const seen = /* @__PURE__ */ new Set();
@@ -55464,7 +56724,7 @@ async function warnSkillSubagentNameCollisions(params) {
55464
56724
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
55465
56725
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
55466
56726
  const subagentsProcessor = new SubagentsProcessor({
55467
- inputRoot: config.getInputRoot(),
56727
+ inputRoots: config.getInputRoots(),
55468
56728
  toolTarget,
55469
56729
  global,
55470
56730
  logger
@@ -55472,7 +56732,7 @@ async function warnSkillSubagentNameCollisions(params) {
55472
56732
  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
56733
  if (subagentNames.size === 0) continue;
55474
56734
  const skillNames = (await new SkillsProcessor({
55475
- inputRoot: config.getInputRoot(),
56735
+ inputRoots: config.getInputRoots(),
55476
56736
  toolTarget,
55477
56737
  global,
55478
56738
  logger
@@ -55521,6 +56781,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55521
56781
  */
55522
56782
  async function generate(params) {
55523
56783
  const { config, logger } = params;
56784
+ resetRootShadowingWarnings({ logger });
55524
56785
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55525
56786
  toolTarget,
55526
56787
  outputRoot
@@ -55659,7 +56920,7 @@ async function generateRulesCore(params) {
55659
56920
  toolTarget,
55660
56921
  global: config.getGlobal()
55661
56922
  }),
55662
- inputRoot: config.getInputRoot(),
56923
+ inputRoots: config.getInputRoots(),
55663
56924
  toolTarget,
55664
56925
  global: config.getGlobal(),
55665
56926
  simulateCommands: config.getSimulateCommands(),
@@ -55709,7 +56970,7 @@ async function generateIgnoreCore(params) {
55709
56970
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55710
56971
  const processor = new IgnoreProcessor({
55711
56972
  outputRoot,
55712
- inputRoot: config.getInputRoot(),
56973
+ inputRoots: config.getInputRoots(),
55713
56974
  toolTarget,
55714
56975
  global,
55715
56976
  dryRun: config.isPreviewMode(),
@@ -55756,7 +57017,7 @@ async function generateMcpCore(params) {
55756
57017
  toolTarget,
55757
57018
  global: config.getGlobal()
55758
57019
  }),
55759
- inputRoot: config.getInputRoot(),
57020
+ inputRoots: config.getInputRoots(),
55760
57021
  toolTarget,
55761
57022
  global: config.getGlobal(),
55762
57023
  dryRun: config.isPreviewMode(),
@@ -55802,7 +57063,7 @@ async function generateCommandsCore(params) {
55802
57063
  toolTarget,
55803
57064
  global: config.getGlobal()
55804
57065
  }),
55805
- inputRoot: config.getInputRoot(),
57066
+ inputRoots: config.getInputRoots(),
55806
57067
  toolTarget,
55807
57068
  global: config.getGlobal(),
55808
57069
  dryRun: config.isPreviewMode(),
@@ -55849,7 +57110,7 @@ async function generateSubagentsCore(params) {
55849
57110
  toolTarget,
55850
57111
  global: config.getGlobal()
55851
57112
  }),
55852
- inputRoot: config.getInputRoot(),
57113
+ inputRoots: config.getInputRoots(),
55853
57114
  toolTarget,
55854
57115
  global: config.getGlobal(),
55855
57116
  dryRun: config.isPreviewMode(),
@@ -55896,7 +57157,7 @@ async function generateSkillsCore(params) {
55896
57157
  toolTarget,
55897
57158
  global: config.getGlobal()
55898
57159
  }),
55899
- inputRoot: config.getInputRoot(),
57160
+ inputRoots: config.getInputRoots(),
55900
57161
  toolTarget,
55901
57162
  global: config.getGlobal(),
55902
57163
  dryRun: config.isPreviewMode(),
@@ -55941,7 +57202,7 @@ async function generateHooksCore(params) {
55941
57202
  toolTarget,
55942
57203
  global: config.getGlobal()
55943
57204
  }),
55944
- inputRoot: config.getInputRoot(),
57205
+ inputRoots: config.getInputRoots(),
55945
57206
  toolTarget,
55946
57207
  global: config.getGlobal(),
55947
57208
  dryRun: config.isPreviewMode(),
@@ -55983,7 +57244,7 @@ async function generatePermissionsCore(params) {
55983
57244
  toolTarget,
55984
57245
  global: config.getGlobal()
55985
57246
  }),
55986
- inputRoot: config.getInputRoot(),
57247
+ inputRoots: config.getInputRoots(),
55987
57248
  toolTarget,
55988
57249
  global: config.getGlobal(),
55989
57250
  dryRun: config.isPreviewMode(),
@@ -56029,7 +57290,7 @@ async function generateChecksCore(params) {
56029
57290
  toolTarget,
56030
57291
  global: config.getGlobal()
56031
57292
  }),
56032
- inputRoot: config.getInputRoot(),
57293
+ inputRoots: config.getInputRoots(),
56033
57294
  toolTarget,
56034
57295
  global: config.getGlobal(),
56035
57296
  dryRun: config.isPreviewMode(),
@@ -56401,6 +57662,6 @@ async function importChecksCore(params) {
56401
57662
  return writtenCount;
56402
57663
  }
56403
57664
  //#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 };
57665
+ export { SourceEntrySchema as $, RULESYNC_MCP_SCHEMA_URL as $t, RulesyncRule as A, writeFileBuffer as At, RulesyncCommandFrontmatterSchema as B, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeDirectoryStrict as Ct, RulesyncSubagentFrontmatterSchema as D, resolvePath as Dt, RulesyncSubagent as E, removeTempDirectory as Et, RulesyncHooks as F, ToolTargetSchema as Ft, SHARED_USER_MANAGED_CONFIG_PATHS as G, RULESYNC_HOOKS_FILE_NAME as Gt, RulesyncCheckFrontmatterSchema as H, RULESYNC_CONFIG_SCHEMA_URL as Ht, getRulesyncSourceCandidates as I, MAX_FILE_SIZE as It, mergeInputRootConfigs as J, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Jt, SKILL_FILE_NAME as K, RULESYNC_HOOKS_LEGACY_FILE_NAME as Kt, resolveRulesyncSourceWritePath as L, RULESYNC_AIIGNORE_FILE_NAME as Lt, RulesyncPermissions as M, ALL_TOOL_TARGETS as Mt, RulesyncMcp as N, ALL_TOOL_TARGETS_WITH_WILDCARD as Nt, RulesyncSkill as O, runWithDirectoryRollback as Ot, RulesyncIgnore as P, PACKAGING_TOOL_TARGETS as Pt, GITIGNORE_DESTINATION_KEY as Q, RULESYNC_MCP_RELATIVE_FILE_PATH as Qt, parseJsonc as R, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeDirectory as St, getLocalSkillDirNames as T, removeFileStrict as Tt, stringifyFrontmatter as U, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Ut, RulesyncCheck as V, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Vt, loadYaml as W, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Wt, CONFLICTING_TARGET_PAIRS as X, RULESYNC_MCP_FILE_NAME as Xt, resolveEffectiveInputRoots as Y, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Yt, ConfigFileSchema as Z, RULESYNC_MCP_LEGACY_FILE_NAME as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, getHomeDirectory as _t, convertFromTool as a, RULESYNC_RELATIVE_DIR_PATH as an, CLIError as at, CLAUDECODE_SKILLS_DIR_PATH as b, readFileContent as bt, SubagentsProcessor as c, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as cn, assertTreeContainsNoSymlinks as ct, IgnoreProcessor as d, ALL_FEATURES_WITH_WILDCARD as dn, createTempDirectory as dt, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as en, findControlCharacter as et, HooksProcessor as f, DEPRECATED_FEATURE_REPLACEMENTS as fn, directoryExists as ft, CLAUDECODE_DIR as g, getFileSize as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, findFilesByGlobs as ht, getProcessorRegistryEntry as i, RULESYNC_PERMISSIONS_SCHEMA_URL as in, warnOnConflictingFlags as it, RulesyncRuleFrontmatterSchema as j, writeFileContent as jt, RulesyncSkillFrontmatterSchema as k, toPosixPath as kt, SkillsProcessor as l, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as ln, assertWritablePathInsideRoot as lt, QWENCODE_DIR as m, fileExists as mt, generate as n, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as nn, JsonLogger as nt, isPackagingToolTarget as o, RULESYNC_RULES_RELATIVE_DIR_PATH as on, ErrorCodes as ot, CommandsProcessor as p, formatError as pn, ensureDir as pt, ConfigResolver as q, RULESYNC_HOOKS_RELATIVE_FILE_PATH as qt, inspectInputRoots as r, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as rn, fallbackLogger as rt, RulesProcessor as s, RULESYNC_SKILLS_RELATIVE_DIR_PATH as sn, assertDirectoryIfExists as st, importFromTool as t, RULESYNC_PERMISSIONS_FILE_NAME as tn, ConsoleLogger as tt, McpProcessor as u, ALL_FEATURES as un, checkPathTraversal as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, isSymlink as vt, stripControlCharacters as w, removeFile as wt, ChecksProcessor as x, readFileContentOrNull as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, listDirectoryFiles as yt, RulesyncCommand as z, RULESYNC_CHECKS_RELATIVE_DIR_PATH as zt };
56405
57666
 
56406
- //# sourceMappingURL=import-BPTCMtUS.js.map
57667
+ //# sourceMappingURL=import-DimKwtQ6.js.map