rulesync 16.14.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
@@ -4397,11 +4809,26 @@ const KiloPermissionsOverrideSchema = z.looseObject({
4397
4809
  * rather than which commands are permitted — so it is a loose passthrough on
4398
4810
  * the same terms, merged into the top level of `.claude/settings.json`.
4399
4811
  *
4812
+ * Any other key is a plain top-level `settings.json` key (`editorMode`, `env`,
4813
+ * `model`, ...), deep-merged into the generated file verbatim so settings
4814
+ * Claude Code adds faster than an allowlist can track stay authorable.
4815
+ * The exceptions are the keys another feature owns (`hooks`) and `$schema`.
4816
+ * Keys the target file cannot honor — `Managed`-only and `~/.claude.json`-only
4817
+ * keys in either scope, plus user-scope keys at project scope — are dropped
4818
+ * with a warning rather than written where they would never apply. So are the
4819
+ * keys whose value is a command Claude Code executes (`apiKeyHelper`,
4820
+ * `statusLine`, ...): this file is shareable via `rulesync fetch`, and a file
4821
+ * named for restricting things is not where a command belongs — author those
4822
+ * in `.rulesync/hooks.jsonc` instead.
4823
+ *
4400
4824
  * @example
4401
4825
  * { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
4402
4826
  * @example
4403
4827
  * { "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } } }
4828
+ * @example
4829
+ * { "editorMode": "vim", "env": { "MY_VAR": "1" } }
4404
4830
  * @see https://code.claude.com/docs/en/sandboxing
4831
+ * @see https://code.claude.com/docs/en/settings-reference
4405
4832
  */
4406
4833
  const ClaudecodePermissionsOverrideSchema = z.looseObject({
4407
4834
  permission: z.optional(ToolScopedPermissionSchema),
@@ -5076,6 +5503,15 @@ const CodexcliPermissionsOverrideSchema = z.looseObject({
5076
5503
  sandbox_workspace_write: z.optional(z.looseObject({})),
5077
5504
  apps: z.optional(z.looseObject({})),
5078
5505
  approvals_reviewer: z.optional(z.union([CodexApprovalsReviewerSchema, z.looseObject({})])),
5506
+ /**
5507
+ * The `[tui]` table of `config.toml` (e.g. `vim_mode_default`, `keymap.*`).
5508
+ * Not a permission surface, but like `apps` it is a top-level table with no
5509
+ * canonical category, and Codex adds keys to it faster than an explicit model
5510
+ * could track — so it is a loose passthrough written verbatim.
5511
+ *
5512
+ * @see https://developers.openai.com/codex/config-reference
5513
+ */
5514
+ tui: z.optional(z.looseObject({})),
5079
5515
  git_write_rules: z.optional(z.boolean())
5080
5516
  });
5081
5517
  /**
@@ -5222,15 +5658,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5222
5658
  error: null
5223
5659
  };
5224
5660
  }
5225
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5661
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5226
5662
  const paths = RulesyncPermissions.getSettablePaths();
5663
+ const overrideDirPath = relativeDirPath;
5227
5664
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5228
- const filePath = join(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
5665
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
5666
+ const filePath = join(outputRoot, candidateDirPath, candidate.relativeFilePath);
5229
5667
  if (!await fileExists(filePath)) continue;
5230
5668
  const fileContent = await readFileContent(filePath);
5231
5669
  return new RulesyncPermissions({
5232
5670
  outputRoot,
5233
- relativeDirPath: candidate.relativeDirPath,
5671
+ relativeDirPath: candidateDirPath,
5234
5672
  relativeFilePath: candidate.relativeFilePath,
5235
5673
  fileContent,
5236
5674
  validate
@@ -5394,8 +5832,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5394
5832
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5395
5833
  };
5396
5834
  }
5397
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5398
- 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);
5399
5838
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5400
5839
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5401
5840
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5408,7 +5847,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5408
5847
  };
5409
5848
  return new RulesyncRule({
5410
5849
  outputRoot,
5411
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
5850
+ relativeDirPath: dirPath,
5412
5851
  relativeFilePath,
5413
5852
  frontmatter: validatedFrontmatter,
5414
5853
  body: content.trim(),
@@ -5656,7 +6095,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5656
6095
  "disable-model-invocation": z.optional(z.boolean()),
5657
6096
  "user-invocable": z.optional(z.boolean()),
5658
6097
  enabled: z.optional(z.boolean()),
5659
- "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())
5660
6103
  })),
5661
6104
  grokcli: z.optional(z.looseObject({
5662
6105
  "disable-model-invocation": z.optional(z.boolean()),
@@ -5832,8 +6275,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5832
6275
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5833
6276
  };
5834
6277
  }
5835
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5836
- 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);
5837
6281
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5838
6282
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5839
6283
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5841,7 +6285,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5841
6285
  const filename = basename(relativeFilePath);
5842
6286
  return new RulesyncSubagent({
5843
6287
  outputRoot,
5844
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6288
+ relativeDirPath: dirPath,
5845
6289
  relativeFilePath: filename,
5846
6290
  frontmatter: result.data,
5847
6291
  body: content.trim()
@@ -5851,16 +6295,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5851
6295
  //#endregion
5852
6296
  //#region src/features/skills/skills-utils.ts
5853
6297
  /**
5854
- * 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`).
5855
6301
  */
5856
- async function getLocalSkillDirNames(outputRoot) {
5857
- const skillsDir = join(outputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
6302
+ async function getLocalSkillDirNames(sourceTree) {
6303
+ const skillsDir = join(sourceTree, SKILLS_FEATURE_SUBDIR);
5858
6304
  const names = /* @__PURE__ */ new Set();
5859
6305
  if (!await directoryExists(skillsDir)) return names;
5860
6306
  const dirPaths = await findFilesByGlobs(join(skillsDir, "*"), { type: "dir" });
5861
6307
  for (const dirPath of dirPaths) {
5862
6308
  const name = basename(dirPath);
5863
- if (name === basename(RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH)) continue;
6309
+ if (name === basename(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
5864
6310
  names.add(name);
5865
6311
  }
5866
6312
  return names;
@@ -6173,15 +6619,49 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6173
6619
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6174
6620
  }
6175
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
6176
6643
  //#region src/types/feature-processor.ts
6177
6644
  var FeatureProcessor = class {
6178
6645
  outputRoot;
6179
- 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;
6180
6660
  dryRun;
6181
6661
  logger;
6182
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
6662
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6183
6663
  this.outputRoot = outputRoot;
6184
- this.inputRoot = inputRoot;
6664
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
6185
6665
  this.dryRun = dryRun;
6186
6666
  this.logger = logger;
6187
6667
  }
@@ -6248,6 +6728,198 @@ var FeatureProcessor = class {
6248
6728
  return orphanFiles.length;
6249
6729
  }
6250
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
+ };
6251
6923
  //#endregion
6252
6924
  //#region src/constants/amp-paths.ts
6253
6925
  const AMP_DIR = ".amp";
@@ -7463,7 +8135,8 @@ const CODEXCLI_OVERRIDE_KEYS = [
7463
8135
  "sandbox_mode",
7464
8136
  "sandbox_workspace_write",
7465
8137
  "apps",
7466
- "approvals_reviewer"
8138
+ "approvals_reviewer",
8139
+ "tui"
7467
8140
  ];
7468
8141
  //#endregion
7469
8142
  //#region src/features/shared/shared-config-gateway.ts
@@ -8569,10 +9242,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8569
9242
  toolTarget;
8570
9243
  global;
8571
9244
  getFactory;
8572
- 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 }) {
8573
9246
  super({
8574
9247
  outputRoot,
8575
- inputRoot,
9248
+ inputRoots,
8576
9249
  dryRun,
8577
9250
  logger
8578
9251
  });
@@ -8609,11 +9282,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8609
9282
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8610
9283
  }
8611
9284
  /**
8612
- * Implementation of abstract method from Processor
8613
- * 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`).
8614
9288
  */
8615
- async loadRulesyncFiles() {
8616
- 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);
8617
9294
  if (!await directoryExists(checksDir)) {
8618
9295
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8619
9296
  return [];
@@ -8629,7 +9306,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8629
9306
  const filepath = join(checksDir, mdFile);
8630
9307
  try {
8631
9308
  const rulesyncCheck = await RulesyncCheck.fromFile({
8632
- outputRoot: this.inputRoot,
9309
+ outputRoot: treeParent,
9310
+ relativeDirPath: treeChecksDirPath,
8633
9311
  relativeFilePath: mdFile,
8634
9312
  validate: true
8635
9313
  });
@@ -8640,6 +9318,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8640
9318
  continue;
8641
9319
  }
8642
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
+ });
8643
9336
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8644
9337
  return rulesyncChecks;
8645
9338
  }
@@ -10118,18 +10811,23 @@ function commandSlug(relativeFilePath) {
10118
10811
  return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10119
10812
  }
10120
10813
  /**
10121
- * 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/`.
10122
10820
  *
10123
10821
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10124
10822
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10125
10823
  * current command slug is owned by the commands feature, so the skills
10126
10824
  * feature must neither import it as a skill nor delete it as an orphan
10127
- * skill. Once the command is removed from `.rulesync/commands/`, the
10128
- * directory stops matching and the skills feature cleans it up as a regular
10129
- * 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.
10130
10828
  */
10131
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10132
- 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);
10133
10831
  }
10134
10832
  //#endregion
10135
10833
  //#region src/features/commands/devin-command.ts
@@ -10819,11 +11517,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10819
11517
  * output is lost — so this warns rather than failing the run the way the
10820
11518
  * Hermes check does, where the two surfaces really do write the same path.
10821
11519
  */
10822
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
11520
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10823
11521
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => basename(command.getRelativeFilePath(), ".md")));
10824
11522
  if (commandNames.size === 0) return;
10825
- const skillsRoot = join(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
10826
- 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));
10827
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.`);
10828
11525
  }
10829
11526
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -11595,7 +12292,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11595
12292
  static getExtraSharedWritePaths() {
11596
12293
  return getHermesagentSharedConfigWritePaths();
11597
12294
  }
11598
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
12295
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11599
12296
  const commandSlugs = /* @__PURE__ */ new Set();
11600
12297
  const commandOrigins = /* @__PURE__ */ new Map();
11601
12298
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11607,13 +12304,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11607
12304
  commandOrigins.set(slug, origin);
11608
12305
  commandSlugs.add(slug);
11609
12306
  }
11610
- const skillsRoot = join(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
11611
- const skillFiles = await findFilesByGlobs(join(skillsRoot, "**", "SKILL.md"));
11612
- const collisions = (await Promise.all(skillFiles.map((path) => RulesyncSkill.fromDir({
11613
- outputRoot: inputRoot,
11614
- relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
11615
- dirName: toPosixPath(relative(skillsRoot, dirname(path)))
11616
- })))).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));
11617
12326
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11618
12327
  }
11619
12328
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11746,6 +12455,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11746
12455
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11747
12456
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11748
12457
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
12458
+ const JUNIE_RULES_DIR_NAME = "rules";
12459
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11749
12460
  //#endregion
11750
12461
  //#region src/features/commands/junie-command.ts
11751
12462
  const JunieCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()) });
@@ -13706,10 +14417,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13706
14417
  global;
13707
14418
  getFactory;
13708
14419
  flattenedCommandNaming;
13709
- 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 }) {
13710
14421
  super({
13711
14422
  outputRoot,
13712
- inputRoot,
14423
+ inputRoots,
13713
14424
  dryRun,
13714
14425
  logger
13715
14426
  });
@@ -13724,7 +14435,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13724
14435
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13725
14436
  const factory = this.getFactory(this.toolTarget);
13726
14437
  await factory.class.validateRulesyncCommands?.({
13727
- inputRoot: this.inputRoot,
14438
+ inputRoots: this.inputRoots,
13728
14439
  rulesyncCommands,
13729
14440
  logger: this.logger
13730
14441
  });
@@ -13776,16 +14487,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13776
14487
  return rel;
13777
14488
  }
13778
14489
  /**
13779
- * Implementation of abstract method from FeatureProcessor
13780
- * 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`).
13781
14493
  */
13782
- async loadRulesyncFiles() {
13783
- 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);
13784
14499
  const rulesyncCommandPaths = await findFilesByGlobs(join(basePath, "**", "*.md"));
13785
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13786
- outputRoot: this.inputRoot,
14500
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
14501
+ outputRoot: treeParent,
14502
+ relativeDirPath: treeCommandsDirPath,
13787
14503
  relativeFilePath: this.safeRelativePath(basePath, path)
13788
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
+ });
13789
14520
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13790
14521
  return rulesyncCommands;
13791
14522
  }
@@ -19425,6 +20156,7 @@ function vibeEntryToCanonicalDef(raw) {
19425
20156
  const entry = raw;
19426
20157
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19427
20158
  if (vibeEvent === void 0) return null;
20159
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19428
20160
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19429
20161
  const def = { type: "command" };
19430
20162
  if (typeof entry.command === "string") def.command = entry.command;
@@ -19973,10 +20705,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
19973
20705
  var HooksProcessor = class extends FeatureProcessor {
19974
20706
  toolTarget;
19975
20707
  global;
19976
- 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 }) {
19977
20709
  super({
19978
20710
  outputRoot,
19979
- inputRoot,
20711
+ inputRoots,
19980
20712
  dryRun,
19981
20713
  logger
19982
20714
  });
@@ -19986,9 +20718,17 @@ var HooksProcessor = class extends FeatureProcessor {
19986
20718
  this.global = global;
19987
20719
  }
19988
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];
19989
20728
  try {
19990
20729
  return [await RulesyncHooks.fromFile({
19991
- outputRoot: this.inputRoot,
20730
+ outputRoot: dirname(sourceTree),
20731
+ relativeDirPath: basename(sourceTree),
19992
20732
  validate: true
19993
20733
  })];
19994
20734
  } catch (error) {
@@ -21530,10 +22270,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21530
22270
  getFactory;
21531
22271
  featureOptions;
21532
22272
  global;
21533
- 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 }) {
21534
22274
  super({
21535
22275
  outputRoot,
21536
- inputRoot,
22276
+ inputRoots,
21537
22277
  dryRun,
21538
22278
  logger
21539
22279
  });
@@ -21550,11 +22290,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21550
22290
  }
21551
22291
  /**
21552
22292
  * Implementation of abstract method from FeatureProcessor
21553
- * 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.
21554
22307
  */
21555
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];
21556
22317
  try {
21557
- return [await RulesyncIgnore.fromFile({ outputRoot: this.inputRoot })];
22318
+ return [await RulesyncIgnore.fromFile({
22319
+ outputRoot: dirname(sourceTree),
22320
+ relativeDirPath: basename(sourceTree)
22321
+ })];
21558
22322
  } catch (error) {
21559
22323
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21560
22324
  return [];
@@ -26589,11 +27353,86 @@ function disabledNamesOf(config) {
26589
27353
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26590
27354
  }
26591
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
+ /**
26592
27428
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26593
27429
  * `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
26594
- * Rovo Dev actually consults to switch a server off — recomputed from the
26595
- * existing block so user keys (`mcpConfigPath`, `allowedMcpServers`, ...) and
26596
- * 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.
26597
27436
  */
26598
27437
  var RovodevMcpConfigYaml = class extends ToolFile {
26599
27438
  isDeletable() {
@@ -26726,7 +27565,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26726
27565
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26727
27566
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26728
27567
  else delete existingMcp.disabledMcpServers;
26729
- 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 [];
26730
27579
  const fileContent = applySharedConfigPatch({
26731
27580
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26732
27581
  feature: "mcp",
@@ -27819,10 +28668,10 @@ var McpProcessor = class extends FeatureProcessor {
27819
28668
  toolTarget;
27820
28669
  global;
27821
28670
  getFactory;
27822
- 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 }) {
27823
28672
  super({
27824
28673
  outputRoot,
27825
- inputRoot,
28674
+ inputRoots,
27826
28675
  dryRun,
27827
28676
  logger
27828
28677
  });
@@ -27838,7 +28687,10 @@ var McpProcessor = class extends FeatureProcessor {
27838
28687
  */
27839
28688
  async loadRulesyncFiles() {
27840
28689
  try {
27841
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
28690
+ return [await RulesyncMcp.fromRoots({
28691
+ inputRoots: this.inputRoots,
28692
+ logger: this.logger
28693
+ })];
27842
28694
  } catch (error) {
27843
28695
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27844
28696
  return [];
@@ -29464,8 +30316,9 @@ function deepMergeRecords(base, patch) {
29464
30316
  * emits them only under `--global`.
29465
30317
  *
29466
30318
  * Deliberately NOT listed:
29467
- * - `bwrapPath` / `socatPath`: v2.1.232 added them to the managed-settings
29468
- * approval dialog, which is a consent prompt, not a project-scope rejection.
30319
+ * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
30320
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
30321
+ * rather than emitting them under `--global`.
29469
30322
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29470
30323
  * unit is the individual entry's mode, not the settings key, and the same
29471
30324
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29473,7 +30326,6 @@ function deepMergeRecords(base, patch) {
29473
30326
  * filters those lists per entry instead.
29474
30327
  *
29475
30328
  * @see https://code.claude.com/docs/en/sandboxing
29476
- * @see https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — v2.1.232 scoped `sandbox.ripgrep`
29477
30329
  */
29478
30330
  const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29479
30331
  ["filesystem", "disabled"],
@@ -29482,37 +30334,309 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29482
30334
  ["credentials", "allowPlaintextInject"],
29483
30335
  ["credentials", "awsPairs"],
29484
30336
  ["credentials", "sigv4"],
29485
- ["allowAppleEvents"],
29486
- ["ripgrep"]
30337
+ ["allowAppleEvents"]
29487
30338
  ];
29488
30339
  /**
29489
- * Copy of the authored `sandbox` override with the user/managed-only paths
29490
- * removed, warning once per dropped path. Only the override copy is filtered —
29491
- * a value already hand-written in the target file is left untouched, matching
29492
- * the `qwencode` `security.allowPrivateNetworkHooks` precedent.
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
+ /**
30364
+ * Walks `segments` from `root`, returning the record they name or `undefined` if
30365
+ * any step is missing or not a record. Shared by everything below that addresses
30366
+ * a `sandbox` path, so a nested path added to one of the tables is actually
30367
+ * traversed rather than silently skipped.
30368
+ */
30369
+ function resolveSandboxParent({ root, segments }) {
30370
+ let parent = root;
30371
+ for (const segment of segments) {
30372
+ const next = parent[segment];
30373
+ if (!isPlainRecord(next)) return void 0;
30374
+ parent = next;
30375
+ }
30376
+ return parent;
30377
+ }
30378
+ /**
30379
+ * Deletes `path` from `target` in place and reports whether anything was there,
30380
+ * dropping a container the removal emptied so no `"network": {}` noise is left
30381
+ * behind.
30382
+ */
30383
+ function deleteSandboxPath({ target, path }) {
30384
+ const leaf = path.at(-1);
30385
+ if (leaf === void 0) return false;
30386
+ const parentPath = path.slice(0, -1);
30387
+ const parent = resolveSandboxParent({
30388
+ root: target,
30389
+ segments: parentPath
30390
+ });
30391
+ if (parent === void 0 || parent[leaf] === void 0) return false;
30392
+ delete parent[leaf];
30393
+ for (let depth = parentPath.length; depth > 0; depth--) {
30394
+ const container = resolveSandboxParent({
30395
+ root: target,
30396
+ segments: parentPath.slice(0, depth)
30397
+ });
30398
+ if (container === void 0 || Object.keys(container).length > 0) break;
30399
+ const holder = resolveSandboxParent({
30400
+ root: target,
30401
+ segments: parentPath.slice(0, depth - 1)
30402
+ });
30403
+ const name = parentPath[depth - 1];
30404
+ if (holder === void 0 || name === void 0) break;
30405
+ delete holder[name];
30406
+ }
30407
+ return true;
30408
+ }
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.
29493
30414
  */
29494
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
29495
- const filtered = structuredClone(sandbox);
29496
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
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
+ /**
30422
+ * The `permissions.defaultMode` values that start a session with fewer prompts
30423
+ * than the default. `plan` and `default` are absent because they do not widen
30424
+ * anything.
30425
+ */
30426
+ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
30427
+ acceptEdits: "every file edit is then applied without a prompt",
30428
+ auto: "shell commands are then auto-approved by a classifier rather than by you",
30429
+ bypassPermissions: "every session then starts with no permission prompts at all"
30430
+ };
30431
+ /**
30432
+ * The `permissions` fields that widen rather than restrict: a `defaultMode` that
30433
+ * removes prompts, and `additionalDirectories`, which moves the
30434
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
30435
+ * a shareable permissions file should not loosen the permission system quietly.
30436
+ */
30437
+ function collectWideningPermissionFields({ fields }) {
30438
+ const entries = [];
30439
+ const defaultMode = fields.defaultMode;
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
+ });
30444
+ const additionalDirectories = fields.additionalDirectories;
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;
30450
+ }
30451
+ /**
30452
+ * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
30453
+ * own merge branch, so the top-level refusal in `stripUnhonoredTopLevelKeys`
30454
+ * never sees them — they are refused here on the same grounds, in both scopes:
30455
+ * a fetched `.rulesync/permissions.jsonc` must not be able to point Claude Code
30456
+ * at an executable of its choosing.
30457
+ *
30458
+ * @see https://code.claude.com/docs/en/sandboxing
30459
+ */
30460
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
30461
+ ["ripgrep"],
30462
+ ["bwrapPath"],
30463
+ ["socatPath"]
30464
+ ];
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
+ /**
30478
+ * `sandbox` paths that loosen the sandbox rather than naming something to run:
30479
+ * they let commands out of it, weaken the isolation it provides, or redirect
30480
+ * where its traffic goes. They are written like `env` is — the ordinary uses are
30481
+ * too common to refuse — but never silently, because a fetched override should
30482
+ * not be able to open the sandbox without saying so. `widens` keeps the warning
30483
+ * to the value that actually loosens the policy, so authoring the restrictive
30484
+ * value (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays
30485
+ * quiet. The `allow*` lists are here for a structural reason: Claude Code merges
30486
+ * a list across every settings scope rather than replacing it, so a project file
30487
+ * can only ever add to them. Their counterparts — `denyRead`, `denyWrite`,
30488
+ * `deniedDomains` — merge the same way, but adding to a deny list only ever
30489
+ * narrows the policy, so they are absent.
30490
+ *
30491
+ * @see https://code.claude.com/docs/en/sandboxing
30492
+ */
30493
+ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
30494
+ {
30495
+ path: ["allowAppleEvents"],
30496
+ reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
30497
+ widens: isNotFalse
30498
+ },
30499
+ {
30500
+ path: ["allowUnsandboxedCommands"],
30501
+ reason: "controls whether Claude may retry a blocked command outside the sandbox",
30502
+ widens: isNotFalse
30503
+ },
30504
+ {
30505
+ path: ["autoAllowBashIfSandboxed"],
30506
+ reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
30507
+ widens: isNotFalse
30508
+ },
30509
+ {
30510
+ path: ["enableWeakerNestedSandbox"],
30511
+ reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
30512
+ widens: isNotFalse
30513
+ },
30514
+ {
30515
+ path: ["enableWeakerNetworkIsolation"],
30516
+ reason: "weakens the sandbox's network isolation on macOS",
30517
+ widens: isNotFalse
30518
+ },
30519
+ {
30520
+ path: ["enabled"],
30521
+ reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
30522
+ widens: isNotFalse
30523
+ },
30524
+ {
30525
+ path: ["excludedCommands"],
30526
+ reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
30527
+ widens: isNonEmptyList
30528
+ },
30529
+ {
30530
+ path: ["filesystem", "allowRead"],
30531
+ reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
30532
+ widens: isNonEmptyList
30533
+ },
30534
+ {
30535
+ path: ["filesystem", "allowWrite"],
30536
+ reason: "adds paths sandboxed commands may write to, outside the working directory",
30537
+ widens: isNonEmptyList
30538
+ },
30539
+ {
30540
+ path: ["ignoreViolations"],
30541
+ reason: "hides the sandbox violations it names, so a blocked access stops being reported",
30542
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
30543
+ },
30544
+ {
30545
+ path: ["network", "allowAllUnixSockets"],
30546
+ reason: "lets sandboxed commands connect to every Unix socket",
30547
+ widens: isNotFalse
30548
+ },
30549
+ {
30550
+ path: ["network", "allowedDomains"],
30551
+ reason: "pre-allows domains sandboxed commands may reach without a prompt",
30552
+ widens: isNonEmptyList
30553
+ },
30554
+ {
30555
+ path: ["network", "allowLocalBinding"],
30556
+ reason: "lets sandboxed commands bind local ports",
30557
+ widens: isNotFalse
30558
+ },
30559
+ {
30560
+ path: ["network", "allowMachLookup"],
30561
+ reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
30562
+ widens: isNonEmptyList
30563
+ },
30564
+ {
30565
+ path: ["network", "allowUnixSockets"],
30566
+ reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
30567
+ widens: isNonEmptyList
30568
+ },
30569
+ {
30570
+ path: ["network", "httpProxyPort"],
30571
+ reason: "routes the sandbox's HTTP traffic through the port it names",
30572
+ widens: () => true
30573
+ },
30574
+ {
30575
+ path: ["network", "socksProxyPort"],
30576
+ reason: "routes the sandbox's SOCKS traffic through the port it names",
30577
+ widens: () => true
30578
+ }
30579
+ ];
30580
+ /**
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.
30584
+ */
30585
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
30586
+ const entries = [];
30587
+ for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29497
30588
  const leaf = path.at(-1);
29498
30589
  if (leaf === void 0) continue;
29499
- const parentPath = path.slice(0, -1);
29500
- let parent = filtered;
29501
- for (const segment of parentPath) {
29502
- const next = parent[segment];
29503
- if (!isPlainRecord(next)) {
29504
- parent = {};
29505
- break;
29506
- }
29507
- parent = next;
29508
- }
29509
- if (parent[leaf] === void 0) continue;
29510
- delete parent[leaf];
29511
- const [container] = parentPath;
29512
- if (container !== void 0 && isPlainRecord(filtered[container])) {
29513
- if (Object.keys(filtered[container]).length === 0) delete filtered[container];
29514
- }
29515
- 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.`);
30590
+ const parent = resolveSandboxParent({
30591
+ root: sandbox,
30592
+ segments: path.slice(0, -1)
30593
+ });
30594
+ if (parent === void 0) continue;
30595
+ const value = parent[leaf];
30596
+ if (value === void 0 || !widens(value)) continue;
30597
+ entries.push({
30598
+ label: `sandbox.${path.join(".")}`,
30599
+ reason
30600
+ });
30601
+ }
30602
+ return entries;
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
+ };
30609
+ /**
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.
30613
+ */
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
+ };
30623
+ /**
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.
30628
+ */
30629
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
30630
+ const filtered = structuredClone(sandbox);
30631
+ for (const { paths, warn } of refusals) for (const path of paths) {
30632
+ if (!deleteSandboxPath({
30633
+ target: filtered,
30634
+ path
30635
+ })) continue;
30636
+ logger?.warn(warn({
30637
+ label: `sandbox.${path.join(".")}`,
30638
+ relativeFilePath
30639
+ }));
29516
30640
  }
29517
30641
  return filtered;
29518
30642
  }
@@ -29536,7 +30660,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29536
30660
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29537
30661
  * against cannot slip through a differently-spelled value.
29538
30662
  *
29539
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
30663
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29540
30664
  * value already in the target file is left untouched, which is why the warning
29541
30665
  * points at it.
29542
30666
  *
@@ -29567,6 +30691,271 @@ function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
29567
30691
  else filtered.credentials = filteredCredentials;
29568
30692
  return filtered;
29569
30693
  }
30694
+ /**
30695
+ * Top-level `.claude/settings.json` keys another feature owns, derived from
30696
+ * {@link SHARED_CONFIG_OWNERSHIP} rather than restated here so a feature that
30697
+ * starts owning a new key is excluded from the passthrough automatically
30698
+ * (today: `hooks`, from the hooks feature). Only `replace-owned-keys` entries
30699
+ * name keys; the `custom` policies on this file (`ignore`, `permissions`) own
30700
+ * entries *inside* `permissions`, which the passthrough excludes wholesale.
30701
+ */
30702
+ const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.features ?? {}).flatMap(([feature, policy]) => feature !== "permissions" && policy.kind === "replace-owned-keys" ? policy.ownedKeys : []);
30703
+ /**
30704
+ * Top-level `.claude/settings.json` keys the generic `claudecode` override
30705
+ * passthrough must not carry. `permissions` and `sandbox` have their own merge
30706
+ * branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
30707
+ * respectively), `permission` is rulesync's own canonical tool-scoped block
30708
+ * rather than a settings key, `$schema` is an editor pointer rather than a
30709
+ * Claude Code setting, and the rest belong to the other features writing this
30710
+ * shared file.
30711
+ */
30712
+ const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
30713
+ "permission",
30714
+ "permissions",
30715
+ "sandbox",
30716
+ "$schema",
30717
+ ...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
30718
+ ]);
30719
+ /**
30720
+ * Top-level settings keys Claude Code reads only from user settings, managed
30721
+ * settings and the `--settings` CLI flag — the same restriction
30722
+ * `CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS` records for the `sandbox` subtree, and
30723
+ * the reason the passthrough drops them at project scope instead of committing
30724
+ * a setting that never applies. `rulesync generate --global` writes the user
30725
+ * settings file, so they are emitted there.
30726
+ *
30727
+ * Derived from the per-key **Scope** column of the settings reference: every
30728
+ * top-level key documented as `User or managed` or `User, local, or managed`.
30729
+ *
30730
+ * @see https://code.claude.com/docs/en/settings-reference
30731
+ */
30732
+ const CLAUDECODE_USER_SCOPE_ONLY_KEYS = /* @__PURE__ */ new Set([
30733
+ "askUserQuestionTimeout",
30734
+ "autoMode",
30735
+ "dialogExpiry",
30736
+ "enableArtifact",
30737
+ "footerLinksRegexes",
30738
+ "pluginConfigs",
30739
+ "skipAutoPermissionPrompt",
30740
+ "skipDangerousModePermissionPrompt",
30741
+ "spellcheck",
30742
+ "sshConfigs",
30743
+ "syncClaudeAiSkills",
30744
+ "useAutoModeDuringPlan",
30745
+ "vimInsertModeRemaps"
30746
+ ]);
30747
+ /**
30748
+ * Top-level settings keys neither file rulesync writes can honor, with the file
30749
+ * that does. `Managed` keys are read only from the settings file an organization
30750
+ * deploys, and `Global config` keys only from `~/.claude.json` — rulesync writes
30751
+ * `.claude/settings.json` and `~/.claude/settings.json`, so authoring either
30752
+ * kind through the override would produce a policy that silently never applies.
30753
+ *
30754
+ * Derived from the per-key **Scope** column of the settings reference.
30755
+ *
30756
+ * @see https://code.claude.com/docs/en/settings-reference
30757
+ */
30758
+ const CLAUDECODE_UNHONORED_KEY_SOURCES = {
30759
+ allowAllClaudeAiMcps: "managed settings",
30760
+ allowedChannelPlugins: "managed settings",
30761
+ allowManagedHooksOnly: "managed settings",
30762
+ allowManagedMcpServersOnly: "managed settings",
30763
+ allowManagedPermissionRulesOnly: "managed settings",
30764
+ autoConnectIde: "~/.claude.json",
30765
+ autoInstallIdeExtension: "~/.claude.json",
30766
+ blockedMarketplaces: "managed settings",
30767
+ browserExternalPageTools: "managed settings",
30768
+ channelsEnabled: "managed settings",
30769
+ claudeMd: "managed settings",
30770
+ diffTool: "~/.claude.json",
30771
+ disableBrowserExternalNavigation: "managed settings",
30772
+ disableCommandPluginSources: "managed settings",
30773
+ disableMobileSimulatorTools: "managed settings",
30774
+ disableSideloadFlags: "managed settings",
30775
+ externalEditorContext: "~/.claude.json",
30776
+ forceLoginGatewayUrl: "managed settings",
30777
+ forceRemoteSettingsRefresh: "managed settings",
30778
+ parentSettingsBehavior: "managed settings",
30779
+ permissionExplainerEnabled: "~/.claude.json",
30780
+ pluginSuggestionMarketplaces: "managed settings",
30781
+ pluginTrustMessage: "managed settings",
30782
+ requiredMaximumVersion: "managed settings",
30783
+ requiredMinimumVersion: "managed settings",
30784
+ sshHostAllowlist: "managed settings",
30785
+ strictKnownMarketplaces: "managed settings",
30786
+ strictPluginOnlyCustomization: "managed settings",
30787
+ teammateDefaultModel: "~/.claude.json",
30788
+ wslInheritsWindowsSettings: "managed settings"
30789
+ };
30790
+ /**
30791
+ * Top-level settings keys whose value Claude Code **executes**. The generic
30792
+ * passthrough refuses them outright rather than warning: `.rulesync/*` files
30793
+ * are shareable — `rulesync fetch` copies a third party's `permissions.jsonc`
30794
+ * straight into a project — and a file named for *restricting* what an agent
30795
+ * may do is not somewhere a reviewer looks for a command to run. Commands
30796
+ * belong in `.rulesync/hooks.jsonc` and `.rulesync/.mcp.json`, which are read
30797
+ * as executable by anyone reviewing them. Set these by hand in the settings
30798
+ * file if you need them.
30799
+ *
30800
+ * The value is the reason, spliced into the warning.
30801
+ *
30802
+ * @see https://code.claude.com/docs/en/settings-reference
30803
+ */
30804
+ const CLAUDECODE_COMMAND_EXECUTING_KEYS = {
30805
+ apiKeyHelper: "runs the script it names to mint an API key",
30806
+ awsAuthRefresh: "runs the command it names to refresh AWS credentials",
30807
+ awsCredentialExport: "runs the command it names to export AWS credentials",
30808
+ fileSuggestion: "runs its `command` on every `@` file completion",
30809
+ gcpAuthRefresh: "runs the command it names to refresh Google Cloud credentials",
30810
+ otelHeadersHelper: "runs the script it names to build OpenTelemetry headers",
30811
+ policyHelper: "runs the executable it names to compute the managed settings",
30812
+ processWrapper: "wraps every process Claude Code spawns",
30813
+ statusLine: "runs its `command` on every status-line render",
30814
+ subagentStatusLine: "runs its `command` on every subagent status row"
30815
+ };
30816
+ /**
30817
+ * Top-level settings keys the passthrough does write, but never silently: each
30818
+ * one widens what Claude Code trusts or where it sends data, so a value that
30819
+ * arrived with a fetched `.rulesync/permissions.jsonc` should be looked at
30820
+ * deliberately. Warning on write follows the precedent set for Warp's
30821
+ * `command_denylist`, which also replaces a protection when rulesync writes it.
30822
+ *
30823
+ * The value is the reason, spliced into the warning.
30824
+ */
30825
+ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
30826
+ agent: "starts every session as the named subagent, with that subagent's prompt, tools and model",
30827
+ allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
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",
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",
30833
+ disableAllHooks: "controls whether hooks run at all",
30834
+ disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
30835
+ enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
30836
+ enabledMcpjsonServers: "auto-approves the named servers in the project `.mcp.json`",
30837
+ enabledPlugins: "enables plugins, which can ship their own hooks",
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",
30839
+ extraKnownMarketplaces: "registers plugin marketplace sources",
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",
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",
30845
+ skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode 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"
30848
+ };
30849
+ /**
30850
+ * The keys from the table above that only widen at one particular value.
30851
+ * `disableSkillShellExecution: true` turns inline shell execution off, which
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
+ } };
30893
+ /**
30894
+ * A key name is authored data that ends up in a log line, so strip the control
30895
+ * characters that would let it forge a line or hide the warnings beside it, and
30896
+ * cap the length.
30897
+ */
30898
+ function displayKey(key) {
30899
+ const stripped = stripControlCharacters(key);
30900
+ return stripped.length > 80 ? `${stripped.slice(0, 80)}…` : stripped;
30901
+ }
30902
+ /**
30903
+ * Alternate spellings Claude Code accepts for a top-level settings key, mapped
30904
+ * to the canonical key whose **Scope** the alias inherits: "In any settings
30905
+ * file that accepts the canonical key, Claude Code reads the alias exactly as
30906
+ * it reads the canonical key." Resolving through this map before the scope
30907
+ * check keeps an alias from slipping past a restriction its canonical spelling
30908
+ * is caught by — `allowedMarketplaces` is `Managed`, like
30909
+ * `strictKnownMarketplaces`. Both aliases require Claude Code v2.1.232+.
30910
+ *
30911
+ * @see https://code.claude.com/docs/en/settings-reference#marketplace-key-aliases
30912
+ */
30913
+ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
30914
+ additionalMarketplaces: "extraKnownMarketplaces",
30915
+ allowedMarketplaces: "strictKnownMarketplaces"
30916
+ };
30917
+ /**
30918
+ * Copy of the authored top-level passthrough with the keys the target file
30919
+ * cannot honor removed, warning once per dropped key. Like
30920
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30921
+ * already hand-written in the target file is left untouched, which is why the
30922
+ * warning points at it.
30923
+ */
30924
+ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30925
+ const filtered = {};
30926
+ const trustAffecting = [];
30927
+ for (const [key, value] of Object.entries(overrides)) {
30928
+ const shown = displayKey(key);
30929
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
30930
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) {
30931
+ logger?.warn(`Claude Code permissions: '${shown}' ${CLAUDECODE_COMMAND_EXECUTING_KEYS[canonicalKey]}, 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; author commands in .rulesync/hooks.jsonc, or set this key in ${relativeFilePath} by hand.`);
30932
+ continue;
30933
+ }
30934
+ if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) {
30935
+ logger?.warn(`Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`);
30936
+ continue;
30937
+ }
30938
+ if (!global && CLAUDECODE_USER_SCOPE_ONLY_KEYS.has(canonicalKey)) {
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.`);
30940
+ continue;
30941
+ }
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
+ });
30952
+ filtered[key] = value;
30953
+ }
30954
+ return {
30955
+ filtered,
30956
+ trustAffecting
30957
+ };
30958
+ }
29570
30959
  const CLAUDE_PATH_RULE_ALIASES = {
29571
30960
  Write: "Edit",
29572
30961
  NotebookEdit: "Edit",
@@ -29635,9 +31024,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29635
31024
  config,
29636
31025
  logger
29637
31026
  });
31027
+ const trustAffecting = [];
29638
31028
  const overridePermissions = config.claudecode?.permissions;
29639
31029
  if (overridePermissions && typeof overridePermissions === "object") {
29640
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = overridePermissions;
31030
+ const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
31031
+ const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
31032
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
29641
31033
  settings.permissions = {
29642
31034
  ...settings.permissions,
29643
31035
  ...nonListFields
@@ -29645,17 +31037,44 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29645
31037
  }
29646
31038
  const overrideSandbox = config.claudecode?.sandbox;
29647
31039
  if (isPlainRecord(overrideSandbox)) {
29648
- const scopedSandbox = global ? overrideSandbox : stripProjectIgnoredMaskEntries({
29649
- sandbox: stripGlobalOnlySandboxPaths({
29650
- sandbox: overrideSandbox,
29651
- relativeFilePath: paths.relativeFilePath,
29652
- logger
29653
- }),
31040
+ const honorableSandbox = stripSandboxPaths({
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
+ ],
31047
+ relativeFilePath: paths.relativeFilePath,
31048
+ logger
31049
+ });
31050
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
31051
+ sandbox: honorableSandbox,
29654
31052
  relativeFilePath: paths.relativeFilePath,
29655
31053
  logger
29656
31054
  });
31055
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
29657
31056
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
29658
31057
  }
31058
+ const overrideTopLevel = {};
31059
+ for (const [key, value] of Object.entries(config.claudecode ?? {})) {
31060
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
31061
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
31062
+ if (value === void 0) continue;
31063
+ overrideTopLevel[key] = value;
31064
+ }
31065
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
31066
+ overrides: overrideTopLevel,
31067
+ global,
31068
+ relativeFilePath: paths.relativeFilePath,
31069
+ logger
31070
+ });
31071
+ trustAffecting.push(...trustAffectingTopLevel);
31072
+ if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
31073
+ warnOnTrustAffectingEntries({
31074
+ entries: trustAffecting,
31075
+ relativeFilePath: paths.relativeFilePath,
31076
+ logger
31077
+ });
29659
31078
  const managedToolNames = managedClaudeToolNames(config);
29660
31079
  const merged = applyPermissions({
29661
31080
  settings,
@@ -29688,12 +31107,33 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29688
31107
  ask: permissions.ask ?? [],
29689
31108
  deny: permissions.deny ?? []
29690
31109
  });
29691
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
31110
+ const { allow: _a, ask: _k, deny: _d, ...permissionsRest } = permissions;
31111
+ const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
29692
31112
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
29693
31113
  const { sandbox } = settings;
29694
- if (isPlainRecord(sandbox) && Object.keys(sandbox).length > 0) config.claudecode = {
31114
+ if (isPlainRecord(sandbox)) {
31115
+ const importedSandbox = structuredClone(sandbox);
31116
+ for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
31117
+ target: importedSandbox,
31118
+ path
31119
+ });
31120
+ if (Object.keys(importedSandbox).length > 0) config.claudecode = {
31121
+ ...config.claudecode,
31122
+ sandbox: importedSandbox
31123
+ };
31124
+ }
31125
+ const topLevelPassthrough = {};
31126
+ for (const [key, value] of Object.entries(settings)) {
31127
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
31128
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
31129
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) continue;
31130
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
31131
+ if (value === void 0) continue;
31132
+ topLevelPassthrough[key] = value;
31133
+ }
31134
+ if (Object.keys(topLevelPassthrough).length > 0) config.claudecode = {
29695
31135
  ...config.claudecode,
29696
- sandbox
31136
+ ...topLevelPassthrough
29697
31137
  };
29698
31138
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
29699
31139
  }
@@ -34612,11 +36052,8 @@ const TOOL_KEY_TO_CATEGORY = {
34612
36052
  updateConfluencePage: "edit"
34613
36053
  };
34614
36054
  const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
34615
- const OWNED_TOOL_PERMISSION_KEYS = [
34616
- "bash",
34617
- "allowedExternalPaths",
34618
- "default"
34619
- ];
36055
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
36056
+ const MANAGED_BASH_KEYS = ["default", "commands"];
34620
36057
  /**
34621
36058
  * Permissions adapter for Rovo Dev CLI.
34622
36059
  *
@@ -34752,6 +36189,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
34752
36189
  }
34753
36190
  };
34754
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
+ /**
34755
36208
  * Resolve the `toolPermissions` block to write, merging the generated levels
34756
36209
  * over the existing file. Every other top-level key of `config.yml` is the
34757
36210
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -34759,6 +36212,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
34759
36212
  */
34760
36213
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
34761
36214
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
36215
+ warnAboutPreservedSandboxOptOut({
36216
+ existingToolPermissions,
36217
+ filePath,
36218
+ logger
36219
+ });
34762
36220
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
34763
36221
  if (!isRecord$1(existing)) return;
34764
36222
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -34767,9 +36225,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34767
36225
  }
34768
36226
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
34769
36227
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
36228
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
36229
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
34770
36230
  warnAboutDroppedOwnedKeys({
34771
36231
  existingToolPermissions,
34772
36232
  existingTools,
36233
+ existingBash,
34773
36234
  generated,
34774
36235
  filePath,
34775
36236
  logger
@@ -34779,15 +36240,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34779
36240
  delete existingToolPermissions[toolKey];
34780
36241
  delete existingTools[toolKey];
34781
36242
  }
36243
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
34782
36244
  const tools = {
34783
36245
  ...existingTools,
34784
36246
  ...generated.tools
34785
36247
  };
36248
+ const bash = {
36249
+ ...existingBash,
36250
+ ...generated.bash
36251
+ };
34786
36252
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
36253
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
34787
36254
  return {
34788
36255
  ...existingToolPermissions,
34789
36256
  ...generated,
34790
- ...Object.keys(tools).length > 0 ? { tools } : {}
36257
+ ...Object.keys(tools).length > 0 ? { tools } : {},
36258
+ ...Object.keys(bash).length > 0 ? { bash } : {}
34791
36259
  };
34792
36260
  }
34793
36261
  /**
@@ -34796,9 +36264,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34796
36264
  * `/directories` and by an "always allow" answer to a prompt — so their removal
34797
36265
  * must not be silent.
34798
36266
  */
34799
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
36267
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
34800
36268
  const newTools = generated.tools ?? {};
34801
- 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
+ ];
34802
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.`);
34803
36276
  }
34804
36277
  /**
@@ -36906,10 +38379,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
36906
38379
  var PermissionsProcessor = class extends FeatureProcessor {
36907
38380
  toolTarget;
36908
38381
  global;
36909
- 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 }) {
36910
38383
  super({
36911
38384
  outputRoot,
36912
- inputRoot,
38385
+ inputRoots,
36913
38386
  dryRun,
36914
38387
  logger
36915
38388
  });
@@ -36919,9 +38392,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
36919
38392
  this.global = global;
36920
38393
  }
36921
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];
36922
38402
  try {
36923
38403
  return [await RulesyncPermissions.fromFile({
36924
- outputRoot: this.inputRoot,
38404
+ outputRoot: dirname(sourceTree),
38405
+ relativeDirPath: basename(sourceTree),
36925
38406
  validate: true
36926
38407
  })];
36927
38408
  } catch (error) {
@@ -37345,13 +38826,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37345
38826
  //#region src/types/dir-feature-processor.ts
37346
38827
  var DirFeatureProcessor = class {
37347
38828
  outputRoot;
37348
- 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;
37349
38843
  dryRun;
37350
38844
  avoidBlockScalars;
37351
38845
  logger;
37352
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
38846
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37353
38847
  this.outputRoot = outputRoot;
37354
- this.inputRoot = inputRoot;
38848
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [join(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
37355
38849
  this.dryRun = dryRun;
37356
38850
  this.avoidBlockScalars = avoidBlockScalars;
37357
38851
  this.logger = logger;
@@ -39610,9 +41104,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
39610
41104
  * slug is owned by the commands feature: it must not be imported as a
39611
41105
  * skill nor deleted as an orphan skill.
39612
41106
  */
39613
- static async isDirOwned({ dirName, inputRoot }) {
41107
+ static async isDirOwned({ dirName, inputRoots }) {
39614
41108
  return !await rulesyncCommandSlugExists({
39615
- inputRoot,
41109
+ inputRoots,
39616
41110
  dirName
39617
41111
  });
39618
41112
  }
@@ -39662,7 +41156,11 @@ const FactorydroidSkillFrontmatterSchema = z.looseObject({
39662
41156
  "user-invocable": z.optional(z.boolean()),
39663
41157
  "disable-model-invocation": z.optional(z.boolean()),
39664
41158
  enabled: z.optional(z.boolean()),
39665
- "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())
39666
41164
  });
39667
41165
  /**
39668
41166
  * Represents a Factory Droid skill directory.
@@ -39715,16 +41213,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
39715
41213
  };
39716
41214
  }
39717
41215
  toRulesyncSkill() {
39718
- const frontmatter = this.getFrontmatter();
39719
- const factorydroidBlock = {
39720
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
39721
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
39722
- ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
39723
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
39724
- };
41216
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
39725
41217
  const rulesyncFrontmatter = {
39726
- name: frontmatter.name,
39727
- description: frontmatter.description,
41218
+ name,
41219
+ description,
39728
41220
  targets: ["*"],
39729
41221
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
39730
41222
  };
@@ -39751,13 +41243,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
39751
41243
  rootFrontmatter: rulesyncFrontmatter,
39752
41244
  section: factorydroidSection
39753
41245
  });
41246
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
39754
41247
  const factorydroidFrontmatter = {
39755
41248
  name: rulesyncFrontmatter.name,
39756
41249
  description: rulesyncFrontmatter.description,
41250
+ ...section,
39757
41251
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
39758
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
39759
- ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
39760
- ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
41252
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
39761
41253
  };
39762
41254
  return new FactorydroidSkill({
39763
41255
  outputRoot,
@@ -40169,7 +41661,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40169
41661
  }
40170
41662
  }
40171
41663
  static getSettablePaths(_options) {
40172
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
41664
+ return {
41665
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
41666
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
41667
+ };
40173
41668
  }
40174
41669
  getFrontmatter() {
40175
41670
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -42331,9 +43826,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42331
43826
  * slug is owned by the commands feature: it must not be imported as a
42332
43827
  * skill nor deleted as an orphan skill.
42333
43828
  */
42334
- static async isDirOwned({ dirName, inputRoot }) {
43829
+ static async isDirOwned({ dirName, inputRoots }) {
42335
43830
  return !await rulesyncCommandSlugExists({
42336
- inputRoot,
43831
+ inputRoots,
42337
43832
  dirName
42338
43833
  });
42339
43834
  }
@@ -42891,10 +44386,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42891
44386
  toolTarget;
42892
44387
  global;
42893
44388
  getFactory;
42894
- 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 }) {
42895
44390
  super({
42896
44391
  outputRoot,
42897
- inputRoot,
44392
+ inputRoots,
42898
44393
  dryRun,
42899
44394
  avoidBlockScalars: toolTarget === "cursor",
42900
44395
  logger
@@ -42932,39 +44427,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42932
44427
  return rulesyncSkills;
42933
44428
  }
42934
44429
  /**
42935
- * Implementation of abstract method from DirFeatureProcessor
42936
- * Load and parse rulesync skill directories from .rulesync/skills/ directory
42937
- * and also from .rulesync/skills/.curated/ for remote skills.
42938
- * 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.
42939
44434
  */
42940
- async loadRulesyncDirs() {
42941
- 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)];
42942
44441
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
42943
- outputRoot: this.inputRoot,
44442
+ outputRoot: treeParent,
44443
+ relativeDirPath: treeSkillsDirPath,
42944
44444
  dirName,
42945
44445
  global: this.global
42946
44446
  })));
42947
- const localSkillNames = new Set(localDirNames);
42948
- 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);
42949
44449
  let curatedSkills = [];
42950
44450
  if (await directoryExists(curatedDirPath)) {
42951
44451
  const nonConflicting = (await findFilesByGlobs(join(curatedDirPath, "*"), { type: "dir" })).map((path) => basename(path)).filter((name) => {
42952
- if (localSkillNames.has(name)) {
42953
- this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
42954
- return false;
42955
- }
42956
- 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;
42957
44463
  });
42958
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
42959
44464
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
42960
- outputRoot: this.inputRoot,
42961
- relativeDirPath: curatedRelativeDirPath,
44465
+ outputRoot: treeParent,
44466
+ relativeDirPath: treeCuratedSkillsDirPath,
42962
44467
  dirName,
42963
44468
  global: this.global
42964
44469
  })));
42965
44470
  }
42966
- const allSkills = [...localSkills, ...curatedSkills];
42967
- 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`);
42968
44491
  return allSkills;
42969
44492
  }
42970
44493
  /**
@@ -42980,7 +44503,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42980
44503
  }) : [];
42981
44504
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
42982
44505
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
42983
- 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
+ };
42984
44517
  const toolSkills = [];
42985
44518
  for (const root of roots) {
42986
44519
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -42996,54 +44529,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42996
44529
  outputRoot: rootOutputRoot,
42997
44530
  relativeDirPath,
42998
44531
  dirName,
42999
- inputRoot: this.inputRoot
44532
+ inputRoots: this.inputRoots
43000
44533
  })) continue;
43001
44534
  ownedDirNames.push(dirName);
43002
44535
  }
43003
44536
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
44537
+ const sourcePath = join(relativeDirPath, dirName);
43004
44538
  try {
43005
- return await factory.class.fromDir({
43006
- outputRoot: rootOutputRoot,
43007
- relativeDirPath,
43008
- dirName,
43009
- global: this.global
43010
- });
44539
+ return {
44540
+ skill: await factory.class.fromDir({
44541
+ outputRoot: rootOutputRoot,
44542
+ relativeDirPath,
44543
+ dirName,
44544
+ global: this.global
44545
+ }),
44546
+ sourcePath
44547
+ };
43011
44548
  } catch (error) {
43012
44549
  if (!isLenientRoot) throw error;
43013
- this.logger.warn(`Skipping ${join(relativeDirPath, dirName)}: ${formatError(error)}`);
44550
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43014
44551
  return null;
43015
44552
  }
43016
- }))).filter((skill) => skill !== null);
43017
- for (const skill of directorySkills) {
43018
- const skillName = skill.getImportIdentity();
43019
- if (seenSkillNames.has(skillName)) continue;
43020
- seenSkillNames.add(skillName);
43021
- toolSkills.push(skill);
43022
- }
44553
+ }))).filter((loaded) => loaded !== null);
44554
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
44555
+ skill,
44556
+ relativeDirPath,
44557
+ sourcePath
44558
+ })) toolSkills.push(skill);
43023
44559
  if (!factory.class.fromFlatFile) continue;
43024
44560
  const fromFlatFile = factory.class.fromFlatFile;
43025
44561
  const directoryStems = new Set(ownedDirNames);
43026
44562
  const flatFilePaths = (await findFilesByGlobs(join(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has(basename(filePath, ".md")));
43027
44563
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
44564
+ const sourcePath = join(relativeDirPath, basename(filePath));
43028
44565
  try {
43029
- return await fromFlatFile({
43030
- outputRoot: rootOutputRoot,
43031
- relativeDirPath,
43032
- relativeFilePath: basename(filePath),
43033
- global: this.global
43034
- });
44566
+ return {
44567
+ skill: await fromFlatFile({
44568
+ outputRoot: rootOutputRoot,
44569
+ relativeDirPath,
44570
+ relativeFilePath: basename(filePath),
44571
+ global: this.global
44572
+ }),
44573
+ sourcePath
44574
+ };
43035
44575
  } catch (error) {
43036
44576
  if (!isLenientRoot) throw error;
43037
- this.logger.warn(`Skipping ${join(relativeDirPath, basename(filePath))}: ${formatError(error)}`);
44577
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43038
44578
  return null;
43039
44579
  }
43040
- }))).filter((skill) => skill !== null);
43041
- for (const skill of flatSkills) {
43042
- const skillName = skill.getImportIdentity();
43043
- if (seenSkillNames.has(skillName)) continue;
43044
- seenSkillNames.add(skillName);
43045
- toolSkills.push(skill);
43046
- }
44580
+ }))).filter((loaded) => loaded !== null);
44581
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
44582
+ skill,
44583
+ relativeDirPath,
44584
+ sourcePath
44585
+ })) toolSkills.push(skill);
43047
44586
  }
43048
44587
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43049
44588
  return toolSkills;
@@ -43073,7 +44612,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43073
44612
  outputRoot: this.outputRoot,
43074
44613
  relativeDirPath: root,
43075
44614
  dirName,
43076
- inputRoot: this.inputRoot
44615
+ inputRoots: this.inputRoots
43077
44616
  })) continue;
43078
44617
  const toolSkill = factory.class.forDeletion({
43079
44618
  outputRoot: this.outputRoot,
@@ -45831,6 +47370,7 @@ const JunieSubagentFrontmatterSchema = z.looseObject({
45831
47370
  disallowedTools: z.optional(z.union([z.string(), z.array(z.string())])),
45832
47371
  mcpServers: z.optional(z.union([z.string(), z.array(z.string())])),
45833
47372
  model: z.optional(z.string()),
47373
+ permissionMode: z.optional(z.string()),
45834
47374
  reasoningLevel: z.optional(z.string()),
45835
47375
  maxTurns: z.optional(z.number()),
45836
47376
  skills: z.optional(z.union([z.string(), z.array(z.string())])),
@@ -47811,14 +49351,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
47811
49351
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
47812
49352
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
47813
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>";
47814
49365
  var SubagentsProcessor = class extends FeatureProcessor {
47815
49366
  toolTarget;
47816
49367
  global;
47817
49368
  getFactory;
47818
- 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 }) {
47819
49370
  super({
47820
49371
  outputRoot,
47821
- inputRoot,
49372
+ inputRoots,
47822
49373
  dryRun,
47823
49374
  logger
47824
49375
  });
@@ -47864,24 +49415,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
47864
49415
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
47865
49416
  }
47866
49417
  const uniqueRulesyncSubagents = [];
47867
- const seenOutputPaths = /* @__PURE__ */ new Set();
49418
+ const claimedOutputPaths = new ClaimedIdentities();
47868
49419
  for (const rulesyncSubagent of rulesyncSubagents) {
47869
49420
  const outputPath = join(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
47870
- if (seenOutputPaths.has(outputPath)) {
47871
- 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.`);
47872
49427
  continue;
47873
49428
  }
47874
- seenOutputPaths.add(outputPath);
47875
49429
  uniqueRulesyncSubagents.push(rulesyncSubagent);
47876
49430
  }
47877
49431
  return uniqueRulesyncSubagents;
47878
49432
  }
47879
49433
  /**
47880
- * Implementation of abstract method from Processor
47881
- * 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`).
47882
49437
  */
47883
- async loadRulesyncFiles() {
47884
- 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);
47885
49443
  if (!await directoryExists(subagentsDir)) {
47886
49444
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
47887
49445
  return [];
@@ -47897,7 +49455,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
47897
49455
  const filepath = join(subagentsDir, mdFile);
47898
49456
  try {
47899
49457
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
47900
- outputRoot: this.inputRoot,
49458
+ outputRoot: treeParent,
49459
+ relativeDirPath: treeSubagentsDirPath,
47901
49460
  relativeFilePath: mdFile,
47902
49461
  validate: true
47903
49462
  });
@@ -47908,8 +49467,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
47908
49467
  continue;
47909
49468
  }
47910
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
+ });
47911
49486
  if (rulesyncSubagents.length === 0) {
47912
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
49487
+ this.logger.debug(`No valid subagents found`);
47913
49488
  return [];
47914
49489
  }
47915
49490
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -47924,7 +49499,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
47924
49499
  const paths = factory.class.getSettablePaths({ global: this.global });
47925
49500
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
47926
49501
  const toolSubagents = [];
47927
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
49502
+ const claimedRelativeFilePaths = new ClaimedIdentities();
47928
49503
  for (const root of roots) {
47929
49504
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
47930
49505
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -47963,37 +49538,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
47963
49538
  relativeFilePath: toRelativeFilePath(path),
47964
49539
  global: this.global
47965
49540
  })));
47966
- const deduped = [];
47967
- for (const subagent of loaded) {
47968
- const key = subagent.getImportIdentity();
47969
- if (seenRelativeFilePaths.has(key)) {
47970
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping the one from a higher-precedence directory and ignoring this copy.`);
47971
- continue;
47972
- }
47973
- seenRelativeFilePaths.add(key);
47974
- deduped.push(subagent);
47975
- }
47976
- toolSubagents.push(...deduped);
49541
+ toolSubagents.push(...this.claimStandaloneSubagents({
49542
+ loaded,
49543
+ dirPath,
49544
+ claimedRelativeFilePaths
49545
+ }));
47977
49546
  }
47978
49547
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
47979
49548
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
47980
49549
  outputRoot: this.outputRoot,
47981
49550
  global: this.global
47982
49551
  });
47983
- for (const subagent of additionalSubagents) {
47984
- const key = subagent.getImportIdentity();
47985
- if (seenRelativeFilePaths.has(key)) {
47986
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping the standalone file and ignoring the inline copy.`);
47987
- continue;
47988
- }
47989
- seenRelativeFilePaths.add(key);
47990
- toolSubagents.push(subagent);
47991
- }
49552
+ toolSubagents.push(...this.claimInlineSubagents({
49553
+ additionalSubagents,
49554
+ claimedRelativeFilePaths
49555
+ }));
47992
49556
  }
47993
49557
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
47994
49558
  return toolSubagents;
47995
49559
  }
47996
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
+ /**
47997
49612
  * Implementation of abstract method from FeatureProcessor
47998
49613
  * Return the tool targets that this processor supports
47999
49614
  */
@@ -50923,17 +52538,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
50923
52538
  * Rule generator for JetBrains Junie AI coding agent
50924
52539
  *
50925
52540
  * Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
50926
- * resolves project guidelines **first-match-wins**: `.junie/AGENTS.md` → root
50927
- * `AGENTS.md` legacy `.junie/guidelines.md` / `.junie/guidelines/`. Only the
50928
- * first match is loaded, it documents no `@`-reference or file-inclusion
50929
- * mechanism, and no `.junie/memories/` read path exists so non-root rules
50930
- * are folded into the single root `.junie/AGENTS.md` by the RulesProcessor
50931
- * (`nonRoot` is `undefined`, mirroring the warp / deepagents
50932
- * 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
50933
52555
  * `.junie/guidelines.md` is still accepted as an import fallback, but
50934
52556
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
50935
52557
  * without frontmatter requirements.
50936
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
+ *
50937
52567
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
50938
52568
  * these user-scope guidelines with the project guidelines (both are included
50939
52569
  * and marked clearly).
@@ -50955,6 +52585,14 @@ var JunieRule = class JunieRule extends ToolRule {
50955
52585
  alternativeRoots: [{
50956
52586
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
50957
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
50958
52596
  }]
50959
52597
  };
50960
52598
  }
@@ -50965,7 +52603,7 @@ var JunieRule = class JunieRule extends ToolRule {
50965
52603
  static isRootRelativeFilePath(relativeFilePath) {
50966
52604
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
50967
52605
  }
50968
- 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 }) {
50969
52607
  if (global) {
50970
52608
  const paths = this.getSettablePaths({ global: true });
50971
52609
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -50979,7 +52617,8 @@ var JunieRule = class JunieRule extends ToolRule {
50979
52617
  root: true
50980
52618
  });
50981
52619
  }
50982
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
52620
+ const settablePaths = this.getSettablePaths();
52621
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
50983
52622
  const relativePath = join(relativeDirPath, relativeFilePath);
50984
52623
  const fileContent = await readFileContent(join(outputRoot, relativePath));
50985
52624
  return new JunieRule({
@@ -50988,7 +52627,7 @@ var JunieRule = class JunieRule extends ToolRule {
50988
52627
  relativeFilePath,
50989
52628
  fileContent,
50990
52629
  validate,
50991
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
52630
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
50992
52631
  });
50993
52632
  }
50994
52633
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53340,10 +54979,23 @@ const defaultGetFactory = (target) => {
53340
54979
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53341
54980
  return factory;
53342
54981
  };
53343
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53344
- 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) => {
53345
54997
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53346
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
54998
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53347
54999
  return [];
53348
55000
  };
53349
55001
  var RulesProcessor = class extends FeatureProcessor {
@@ -53355,10 +55007,10 @@ var RulesProcessor = class extends FeatureProcessor {
53355
55007
  getFactory;
53356
55008
  skills;
53357
55009
  featureOptions;
53358
- 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 }) {
53359
55011
  super({
53360
55012
  outputRoot,
53361
- inputRoot,
55013
+ inputRoots,
53362
55014
  dryRun,
53363
55015
  logger
53364
55016
  });
@@ -53733,25 +55385,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
53733
55385
  claimedBy.set(target.toLowerCase(), source);
53734
55386
  continue;
53735
55387
  }
53736
- 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.`);
53737
55389
  }
53738
55390
  return rulesyncRules;
53739
55391
  }
53740
55392
  /**
53741
- * Implementation of abstract method from FeatureProcessor
53742
- * 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.
53743
55401
  */
53744
- async loadRulesyncFiles() {
53745
- const rulesyncOutputRoot = join(this.inputRoot, RULESYNC_RULES_RELATIVE_DIR_PATH);
53746
- 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);
53747
55408
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs(join(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs(join(curatedOutputRoot, "**", "*.md"))]);
53748
55409
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
53749
55410
  const localFiles = files.filter((file) => !relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`));
53750
- const localRelativePaths = new Set(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
55411
+ const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
53751
55412
  const curatedFiles = files.filter((file) => relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`)).map((file) => ({
53752
55413
  file,
53753
55414
  relativeFilePath: relative(curatedOutputRoot, file)
53754
- })).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
+ });
53755
55427
  const selectedFiles = [...localFiles.map((file) => ({
53756
55428
  file,
53757
55429
  sourceRelativeFilePath: relative(rulesyncOutputRoot, file),
@@ -53761,25 +55433,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
53761
55433
  sourceRelativeFilePath: join(".curated", relativeFilePath),
53762
55434
  relativeFilePath
53763
55435
  }))];
53764
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
53765
- 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 }) => {
53766
55438
  checkPathTraversal({
53767
55439
  relativePath: sourceRelativeFilePath,
53768
55440
  intendedRootDir: rulesyncOutputRoot
53769
55441
  });
53770
55442
  const rule = await RulesyncRule.fromFile({
53771
- outputRoot: this.inputRoot,
55443
+ outputRoot: treeParent,
55444
+ relativeDirPath: treeRulesDirPath,
53772
55445
  relativeFilePath: sourceRelativeFilePath
53773
55446
  });
53774
55447
  if (sourceRelativeFilePath === relativeFilePath) return rule;
53775
55448
  return new RulesyncRule({
53776
- outputRoot: this.inputRoot,
53777
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
55449
+ outputRoot: treeParent,
55450
+ relativeDirPath: treeRulesDirPath,
53778
55451
  relativeFilePath,
53779
55452
  frontmatter: rule.getFrontmatter(),
53780
55453
  body: rule.getBody()
53781
55454
  });
53782
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
+ });
53783
55471
  const factory = this.getFactory(this.toolTarget);
53784
55472
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
53785
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}.`);
@@ -53873,23 +55561,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
53873
55561
  });
53874
55562
  }).filter((rule) => rule.isDeletable());
53875
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)) : [];
53876
55595
  const rootToolRules = await (async () => {
53877
55596
  if (!settablePaths.root) return [];
53878
- 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));
53879
55598
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
53880
- return await Promise.all(uniqueRootFilePaths.map((filePath) => {
53881
- const relativeDirPath = resolveRelativeDirPath(filePath);
53882
- checkPathTraversal({
53883
- relativePath: relativeDirPath,
53884
- intendedRootDir: this.outputRoot
53885
- });
53886
- return factory.class.fromFile({
53887
- outputRoot: this.outputRoot,
53888
- relativeFilePath: basename(filePath),
53889
- relativeDirPath,
53890
- global: this.global
53891
- });
53892
- }));
55599
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
53893
55600
  })();
53894
55601
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
53895
55602
  const localRootToolRules = await (async () => {
@@ -53901,7 +55608,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
53901
55608
  fileName
53902
55609
  }));
53903
55610
  if (!settablePaths.root) return [];
53904
- 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));
53905
55612
  })();
53906
55613
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
53907
55614
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -53977,6 +55684,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
53977
55684
  }));
53978
55685
  })();
53979
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`);
53980
55710
  const nonRootToolRules = await (async () => {
53981
55711
  if (!settablePaths.nonRoot) return [];
53982
55712
  const nonRootOutputRoot = join(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54009,6 +55739,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54009
55739
  })();
54010
55740
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54011
55741
  return [
55742
+ ...importOnlyToolRules,
54012
55743
  ...rootToolRules,
54013
55744
  ...localRootToolRules,
54014
55745
  ...rootMirrorDeletionRules,
@@ -54816,14 +56547,53 @@ function warnUnsupportedTargets(params) {
54816
56547
  }
54817
56548
  }
54818
56549
  /**
54819
- * Check if .rulesync directory exists.
54820
- *
54821
- * The `.rulesync/` directory lives under the *input* root (where source rules
54822
- * are read from), not under any individual output root, so callers always pass
54823
- * `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.
54824
56556
  */
54825
- async function checkRulesyncDirExists(params) {
54826
- 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
+ };
54827
56597
  }
54828
56598
  function dependsOnReachable(byId, from, target) {
54829
56599
  const seen = /* @__PURE__ */ new Set();
@@ -54954,7 +56724,7 @@ async function warnSkillSubagentNameCollisions(params) {
54954
56724
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
54955
56725
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
54956
56726
  const subagentsProcessor = new SubagentsProcessor({
54957
- inputRoot: config.getInputRoot(),
56727
+ inputRoots: config.getInputRoots(),
54958
56728
  toolTarget,
54959
56729
  global,
54960
56730
  logger
@@ -54962,7 +56732,7 @@ async function warnSkillSubagentNameCollisions(params) {
54962
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()))));
54963
56733
  if (subagentNames.size === 0) continue;
54964
56734
  const skillNames = (await new SkillsProcessor({
54965
- inputRoot: config.getInputRoot(),
56735
+ inputRoots: config.getInputRoots(),
54966
56736
  toolTarget,
54967
56737
  global,
54968
56738
  logger
@@ -55011,6 +56781,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55011
56781
  */
55012
56782
  async function generate(params) {
55013
56783
  const { config, logger } = params;
56784
+ resetRootShadowingWarnings({ logger });
55014
56785
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55015
56786
  toolTarget,
55016
56787
  outputRoot
@@ -55149,7 +56920,7 @@ async function generateRulesCore(params) {
55149
56920
  toolTarget,
55150
56921
  global: config.getGlobal()
55151
56922
  }),
55152
- inputRoot: config.getInputRoot(),
56923
+ inputRoots: config.getInputRoots(),
55153
56924
  toolTarget,
55154
56925
  global: config.getGlobal(),
55155
56926
  simulateCommands: config.getSimulateCommands(),
@@ -55199,7 +56970,7 @@ async function generateIgnoreCore(params) {
55199
56970
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55200
56971
  const processor = new IgnoreProcessor({
55201
56972
  outputRoot,
55202
- inputRoot: config.getInputRoot(),
56973
+ inputRoots: config.getInputRoots(),
55203
56974
  toolTarget,
55204
56975
  global,
55205
56976
  dryRun: config.isPreviewMode(),
@@ -55246,7 +57017,7 @@ async function generateMcpCore(params) {
55246
57017
  toolTarget,
55247
57018
  global: config.getGlobal()
55248
57019
  }),
55249
- inputRoot: config.getInputRoot(),
57020
+ inputRoots: config.getInputRoots(),
55250
57021
  toolTarget,
55251
57022
  global: config.getGlobal(),
55252
57023
  dryRun: config.isPreviewMode(),
@@ -55292,7 +57063,7 @@ async function generateCommandsCore(params) {
55292
57063
  toolTarget,
55293
57064
  global: config.getGlobal()
55294
57065
  }),
55295
- inputRoot: config.getInputRoot(),
57066
+ inputRoots: config.getInputRoots(),
55296
57067
  toolTarget,
55297
57068
  global: config.getGlobal(),
55298
57069
  dryRun: config.isPreviewMode(),
@@ -55339,7 +57110,7 @@ async function generateSubagentsCore(params) {
55339
57110
  toolTarget,
55340
57111
  global: config.getGlobal()
55341
57112
  }),
55342
- inputRoot: config.getInputRoot(),
57113
+ inputRoots: config.getInputRoots(),
55343
57114
  toolTarget,
55344
57115
  global: config.getGlobal(),
55345
57116
  dryRun: config.isPreviewMode(),
@@ -55386,7 +57157,7 @@ async function generateSkillsCore(params) {
55386
57157
  toolTarget,
55387
57158
  global: config.getGlobal()
55388
57159
  }),
55389
- inputRoot: config.getInputRoot(),
57160
+ inputRoots: config.getInputRoots(),
55390
57161
  toolTarget,
55391
57162
  global: config.getGlobal(),
55392
57163
  dryRun: config.isPreviewMode(),
@@ -55431,7 +57202,7 @@ async function generateHooksCore(params) {
55431
57202
  toolTarget,
55432
57203
  global: config.getGlobal()
55433
57204
  }),
55434
- inputRoot: config.getInputRoot(),
57205
+ inputRoots: config.getInputRoots(),
55435
57206
  toolTarget,
55436
57207
  global: config.getGlobal(),
55437
57208
  dryRun: config.isPreviewMode(),
@@ -55473,7 +57244,7 @@ async function generatePermissionsCore(params) {
55473
57244
  toolTarget,
55474
57245
  global: config.getGlobal()
55475
57246
  }),
55476
- inputRoot: config.getInputRoot(),
57247
+ inputRoots: config.getInputRoots(),
55477
57248
  toolTarget,
55478
57249
  global: config.getGlobal(),
55479
57250
  dryRun: config.isPreviewMode(),
@@ -55519,7 +57290,7 @@ async function generateChecksCore(params) {
55519
57290
  toolTarget,
55520
57291
  global: config.getGlobal()
55521
57292
  }),
55522
- inputRoot: config.getInputRoot(),
57293
+ inputRoots: config.getInputRoots(),
55523
57294
  toolTarget,
55524
57295
  global: config.getGlobal(),
55525
57296
  dryRun: config.isPreviewMode(),
@@ -55891,6 +57662,6 @@ async function importChecksCore(params) {
55891
57662
  return writtenCount;
55892
57663
  }
55893
57664
  //#endregion
55894
- export { JsonLogger as $, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, ALL_TOOL_TARGETS_WITH_WILDCARD as At, RulesyncCheck as B, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeTempDirectory as Ct, RulesyncSkill as D, writeFileBuffer as Dt, RulesyncSubagentFrontmatterSchema as E, toPosixPath as Et, getRulesyncSourceCandidates as F, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Ft, SKILL_FILE_NAME as G, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Gt, stringifyFrontmatter as H, RULESYNC_HOOKS_FILE_NAME as Ht, resolveRulesyncSourceWritePath as I, RULESYNC_CHECKS_RELATIVE_DIR_PATH as It, ConfigFileSchema as J, RULESYNC_MCP_LEGACY_FILE_NAME as Jt, ConfigResolver as K, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Kt, parseJsonc as L, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Lt, RulesyncMcp as M, ToolTargetSchema as Mt, RulesyncIgnore as N, MAX_FILE_SIZE as Nt, RulesyncSkillFrontmatterSchema as O, writeFileContent as Ot, RulesyncHooks as P, RULESYNC_AIIGNORE_FILE_NAME as Pt, ConsoleLogger as Q, RULESYNC_PERMISSIONS_FILE_NAME as Qt, RulesyncCommand as R, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeFileStrict as St, RulesyncSubagent as T, runWithDirectoryRollback as Tt, loadYaml as U, RULESYNC_HOOKS_LEGACY_FILE_NAME as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Vt, SHARED_USER_MANAGED_CONFIG_PATHS as W, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Wt, SourceEntrySchema as X, RULESYNC_MCP_SCHEMA_URL as Xt, GITIGNORE_DESTINATION_KEY as Y, RULESYNC_MCP_RELATIVE_FILE_PATH as Yt, findControlCharacter as Z, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, readFileContent as _t, convertFromTool as a, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as an, assertTreeContainsNoSymlinks as at, CLAUDECODE_SKILLS_DIR_PATH as b, removeDirectoryStrict as bt, SubagentsProcessor as c, ALL_FEATURES_WITH_WILDCARD as cn, createTempDirectory as ct, IgnoreProcessor as d, fileExists as dt, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as en, fallbackLogger as et, HooksProcessor as f, findFilesByGlobs as ft, CLAUDECODE_DIR as g, listDirectoryFiles as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, isSymlink as ht, getProcessorRegistryEntry as i, RULESYNC_SKILLS_RELATIVE_DIR_PATH as in, assertDirectoryIfExists as it, RulesyncPermissions as j, PACKAGING_TOOL_TARGETS as jt, RulesyncRule as k, ALL_TOOL_TARGETS as kt, SkillsProcessor as l, DEPRECATED_FEATURE_REPLACEMENTS as ln, directoryExists as lt, QWENCODE_DIR as m, getHomeDirectory as mt, checkRulesyncDirExists as n, RULESYNC_RELATIVE_DIR_PATH as nn, CLIError as nt, isPackagingToolTarget as o, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as on, assertWritablePathInsideRoot as ot, CommandsProcessor as p, getFileSize as pt, CONFLICTING_TARGET_PAIRS as q, RULESYNC_MCP_FILE_NAME as qt, generate as r, RULESYNC_RULES_RELATIVE_DIR_PATH as rn, ErrorCodes as rt, RulesProcessor as s, ALL_FEATURES as sn, checkPathTraversal as st, importFromTool as t, RULESYNC_PERMISSIONS_SCHEMA_URL as tn, warnOnConflictingFlags as tt, McpProcessor as u, formatError as un, ensureDir as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, readFileContentOrNull as vt, getLocalSkillDirNames as w, resolvePath as wt, ChecksProcessor as x, removeFile as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, removeDirectory as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_CONFIG_SCHEMA_URL 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 };
55895
57666
 
55896
- //# sourceMappingURL=import-Ds0QeVp6.js.map
57667
+ //# sourceMappingURL=import-DimKwtQ6.js.map