rulesync 16.15.0 → 16.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,27 +134,34 @@ const isFeatureValueEnabled = (value) => {
134
134
  };
135
135
  //#endregion
136
136
  //#region src/constants/rulesync-paths.ts
137
- const { join: join$290 } = node_path.posix;
137
+ const { join: join$291 } = node_path.posix;
138
138
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
139
139
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
140
140
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
141
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "rules");
142
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$290(RULESYNC_RULES_RELATIVE_DIR_PATH, ".curated");
143
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "commands");
144
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "subagents");
145
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "checks");
146
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
147
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
148
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
149
- join$290(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
150
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
151
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
141
+ const RULES_FEATURE_SUBDIR = "rules";
142
+ const CURATED_RULES_FEATURE_SUBDIR = join$291(RULES_FEATURE_SUBDIR, ".curated");
143
+ const COMMANDS_FEATURE_SUBDIR = "commands";
144
+ const SUBAGENTS_FEATURE_SUBDIR = "subagents";
145
+ const CHECKS_FEATURE_SUBDIR = "checks";
146
+ const SKILLS_FEATURE_SUBDIR = "skills";
147
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$291(SKILLS_FEATURE_SUBDIR, ".curated");
148
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
149
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
150
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
151
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
152
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
153
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
154
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
155
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
156
+ join$291(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
157
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
158
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
152
159
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
153
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
160
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
154
161
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
155
162
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
156
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$290(RULESYNC_RELATIVE_DIR_PATH, "skills");
157
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$290(RULESYNC_SKILLS_RELATIVE_DIR_PATH, ".curated");
163
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
164
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$291(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
158
165
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
159
166
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
160
167
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -1142,6 +1149,7 @@ const ConfigParamsSchema = zod_mini.z.object({
1142
1149
  dryRun: (0, zod_mini.optional)(zod_mini.z.boolean()),
1143
1150
  check: (0, zod_mini.optional)(zod_mini.z.boolean()),
1144
1151
  inputRoot: (0, zod_mini.optional)(zod_mini.z.string()),
1152
+ inputRoots: (0, zod_mini.optional)(zod_mini.z.array(zod_mini.z.string()).check((0, zod_mini.minLength)(1, "inputRoots must be non-empty"))),
1145
1153
  sources: (0, zod_mini.optional)(zod_mini.z.array(SourceEntrySchema))
1146
1154
  });
1147
1155
  zod_mini.z.partial(ConfigParamsSchema);
@@ -1154,14 +1162,39 @@ zod_mini.z.required(ConfigParamsSchema);
1154
1162
  * Normalizes the configuration file location to an absolute path.
1155
1163
  *
1156
1164
  * `ConfigResolver` always supplies the path it actually loaded; the fallback
1157
- * only covers direct programmatic construction, where the conventional
1158
- * location next to the input root is the best guess.
1165
+ * only covers direct programmatic construction. `anchorDir` is the directory
1166
+ * the config file lives next to for the default `.rulesync/` layout this
1167
+ * is the parent of the primary source tree.
1159
1168
  */
1160
- function normalizeConfigFilePath({ configFilePath, inputRoot }) {
1161
- if (configFilePath === void 0) return (0, node_path.join)(inputRoot, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1169
+ function normalizeConfigFilePath({ configFilePath, anchorDir }) {
1170
+ if (configFilePath === void 0) return (0, node_path.join)(anchorDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH);
1162
1171
  return (0, node_path.isAbsolute)(configFilePath) ? configFilePath : (0, node_path.resolve)(configFilePath);
1163
1172
  }
1164
1173
  /**
1174
+ * Resolves any accepted input-root shape (`inputRoot`, `inputRoots`, or
1175
+ * neither) to the canonical non-empty tuple of absolute paths that
1176
+ * `Config` stores. Relative entries are resolved against the current
1177
+ * working directory at call time.
1178
+ *
1179
+ * Semantics (post-refactor):
1180
+ * - Each entry in `inputRoots` is a rulesync **source tree** (the directory
1181
+ * that directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). No implicit
1182
+ * `.rulesync/` join is applied.
1183
+ * - The legacy singular `inputRoot` is a shorthand for "parent of the
1184
+ * default `.rulesync/` source tree", and is expanded to
1185
+ * `[join(inputRoot, ".rulesync")]` before hitting any consumer. This is
1186
+ * the ONLY place `.rulesync` is appended by convention.
1187
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1188
+ * so existing projects with a single `.rulesync/` tree keep working
1189
+ * unchanged.
1190
+ *
1191
+ * Callers must have already run `assertInputRootFieldsExclusive`.
1192
+ */
1193
+ function normalizeInputRoots({ inputRoot, inputRoots }) {
1194
+ const resolved = (inputRoots !== void 0 && inputRoots.length > 0 ? inputRoots : inputRoot !== void 0 ? [(0, node_path.join)(inputRoot, RULESYNC_RELATIVE_DIR_PATH)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)]).map((entry) => (0, node_path.isAbsolute)(entry) ? entry : (0, node_path.resolve)(entry));
1195
+ return [resolved[0], ...resolved.slice(1)];
1196
+ }
1197
+ /**
1165
1198
  * Conflicting target pairs that cannot be used together.
1166
1199
  * Exported so `rulesync doctor` can report the same conflicts as diagnostics
1167
1200
  * without duplicating the list.
@@ -1198,6 +1231,28 @@ const assertTargetsFeaturesExclusive = ({ targets, features }) => {
1198
1231
  if (targets !== void 0 && !Array.isArray(targets) && features !== void 0) throw new Error("Invalid config: when 'targets' is in object form, 'features' must be omitted. Declare per-target features inside the 'targets' object instead.");
1199
1232
  };
1200
1233
  /**
1234
+ * Rejects a single user-authored config file (or a single programmatic
1235
+ * construction) that defines both `inputRoot` and `inputRoots` — the two
1236
+ * fields express the same setting at singular vs. list level and cannot
1237
+ * be combined within one file without ambiguity.
1238
+ *
1239
+ * The check is intentionally per-file: base and local config files can each
1240
+ * be valid in isolation and merge into a state where both survive, and the
1241
+ * resolver picks `inputRoots` in that case (see `resolveEffectiveInputRoots`).
1242
+ * Only a single file declaring both is a genuine authoring error.
1243
+ */
1244
+ const assertInputRootFieldsExclusive = ({ inputRoot, inputRoots }) => {
1245
+ 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.");
1246
+ };
1247
+ /**
1248
+ * Rejects an explicitly supplied empty `inputRoots` list. Omitting the field
1249
+ * selects the conventional default, while an empty list has no meaningful
1250
+ * source-tree semantics and must not be treated as an absent override.
1251
+ */
1252
+ const assertInputRootsNonEmpty = ({ inputRoots }) => {
1253
+ if (inputRoots !== void 0 && inputRoots.length === 0) throw new Error("Invalid config: 'inputRoots' must be non-empty.");
1254
+ };
1255
+ /**
1201
1256
  * Normalizes a post-resolution `ConfigParams` input by rejecting the case
1202
1257
  * where both `targets` and `features` are undefined — a degenerate state
1203
1258
  * that would silently produce a no-op config (no targets, no features).
@@ -1233,14 +1288,35 @@ var Config = class Config {
1233
1288
  gitignoreDestination;
1234
1289
  dryRun;
1235
1290
  check;
1236
- inputRoot;
1291
+ /**
1292
+ * Ordered, absolute-path list of rulesync source trees. Each entry is a
1293
+ * source tree itself — the directory that directly contains `rules/`,
1294
+ * `skills/`, `mcp.jsonc`, etc. No implicit `.rulesync/` join is applied.
1295
+ *
1296
+ * Always non-empty by construction — the constructor either normalizes
1297
+ * an `inputRoot`/`inputRoots` input or falls back to a single-element
1298
+ * list containing `join(<cwd>, ".rulesync")`.
1299
+ *
1300
+ * `inputRoot` (singular) is a deprecated backward-compatibility alias
1301
+ * that expands to `[join(inputRoot, ".rulesync")]`.
1302
+ *
1303
+ * Typed as a non-empty tuple so the one internal caller that legitimately
1304
+ * needs "the primary root" (`normalizeConfigFilePath` fallback) can index
1305
+ * `[0]` without a runtime null-check.
1306
+ */
1307
+ inputRoots;
1237
1308
  configFilePath;
1238
1309
  sources;
1239
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, configFilePath, sources, configFileTargets }) {
1310
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
1240
1311
  assertTargetsFeaturesExclusive({
1241
1312
  targets,
1242
1313
  features
1243
1314
  });
1315
+ assertInputRootFieldsExclusive({
1316
+ inputRoot,
1317
+ inputRoots
1318
+ });
1319
+ assertInputRootsNonEmpty({ inputRoots });
1244
1320
  assertTargetsOrFeaturesProvided({
1245
1321
  targets,
1246
1322
  features
@@ -1268,10 +1344,13 @@ var Config = class Config {
1268
1344
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
1269
1345
  this.dryRun = dryRun ?? false;
1270
1346
  this.check = check ?? false;
1271
- this.inputRoot = inputRoot === void 0 ? process.cwd() : (0, node_path.isAbsolute)(inputRoot) ? inputRoot : (0, node_path.resolve)(inputRoot);
1347
+ this.inputRoots = normalizeInputRoots({
1348
+ inputRoot,
1349
+ inputRoots
1350
+ });
1272
1351
  this.configFilePath = normalizeConfigFilePath({
1273
1352
  configFilePath,
1274
- inputRoot: this.inputRoot
1353
+ anchorDir: (0, node_path.dirname)(this.inputRoots[0])
1275
1354
  });
1276
1355
  this.sources = sources ?? [];
1277
1356
  }
@@ -1455,16 +1534,22 @@ var Config = class Config {
1455
1534
  return this.check;
1456
1535
  }
1457
1536
  /**
1458
- * Returns the directory containing the `.rulesync/` source files. The value
1459
- * is always an absolute path captured at config-construction time, so this
1460
- * accessor is pure and never depends on a live `process.cwd()` read.
1537
+ * Returns the ordered list of rulesync source trees. Each entry is the
1538
+ * source tree itself the directory that directly contains `rules/`,
1539
+ * `skills/`, `mcp.jsonc`, etc. Values are absolute paths captured at
1540
+ * config-construction time, so this accessor is pure and never depends on
1541
+ * a live `process.cwd()` read.
1461
1542
  *
1462
- * When no `inputRoot` was supplied to the constructor, `process.cwd()` is
1463
- * snapshotted once during construction. When a relative `inputRoot` is
1464
- * supplied, it is resolved to absolute against the construction-time cwd.
1543
+ * The returned tuple is always non-empty: when no `inputRoot`/`inputRoots`
1544
+ * was supplied, `[join(process.cwd(), ".rulesync")]` is snapshotted once
1545
+ * during construction. The first entry is the required base source tree.
1546
+ * Later entries are optional overlays and may be absent; when present, they
1547
+ * take precedence when the same relative source path exists in more than
1548
+ * one root (see per-feature merge policies in the processor
1549
+ * `loadRulesync*` methods).
1465
1550
  */
1466
- getInputRoot() {
1467
- return this.inputRoot;
1551
+ getInputRoots() {
1552
+ return this.inputRoots;
1468
1553
  }
1469
1554
  /**
1470
1555
  * Returns the absolute path of the configuration file this config was
@@ -1505,6 +1590,7 @@ const getDefaults = () => ({
1505
1590
  dryRun: false,
1506
1591
  check: false,
1507
1592
  inputRoot: void 0,
1593
+ inputRoots: void 0,
1508
1594
  sources: []
1509
1595
  });
1510
1596
  const loadConfigFromFile = async (filePath) => {
@@ -1516,8 +1602,23 @@ const loadConfigFromFile = async (filePath) => {
1516
1602
  targets: configParams.targets,
1517
1603
  features: configParams.features
1518
1604
  });
1605
+ try {
1606
+ assertInputRootFieldsExclusive({
1607
+ inputRoot: configParams.inputRoot,
1608
+ inputRoots: configParams.inputRoots
1609
+ });
1610
+ } catch (error) {
1611
+ const detail = error instanceof Error ? error.message : String(error);
1612
+ throw new Error(`${detail} (in ${JSON.stringify(filePath)})`, { cause: error });
1613
+ }
1519
1614
  return configParams;
1520
1615
  };
1616
+ function mergeInputRootConfigs({ baseConfig, localConfig }) {
1617
+ return {
1618
+ inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1619
+ inputRoots: localConfig.inputRoots ?? baseConfig.inputRoots
1620
+ };
1621
+ }
1521
1622
  const mergeConfigs = (baseConfig, localConfig) => {
1522
1623
  return {
1523
1624
  targets: localConfig.targets ?? baseConfig.targets,
@@ -1535,7 +1636,10 @@ const mergeConfigs = (baseConfig, localConfig) => {
1535
1636
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
1536
1637
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
1537
1638
  check: localConfig.check ?? baseConfig.check,
1538
- inputRoot: localConfig.inputRoot ?? baseConfig.inputRoot,
1639
+ ...mergeInputRootConfigs({
1640
+ baseConfig,
1641
+ localConfig
1642
+ }),
1539
1643
  sources: localConfig.sources ?? baseConfig.sources
1540
1644
  };
1541
1645
  };
@@ -1564,13 +1668,14 @@ function assertMergedTargetsFeaturesExclusive({ configByFile, validatedConfigPat
1564
1668
  }
1565
1669
  }
1566
1670
  /**
1567
- * Resolve the effective `global` flag. When an `inputRoot` is in play the user
1568
- * is decoupling source from output, so a config-file `global: true` is dropped
1569
- * (unless the caller also explicitly passes `global`); a warning is emitted in
1671
+ * Resolve the effective `global` flag. When an input root (singular
1672
+ * `inputRoot` or plural `inputRoots`) is in play the user is decoupling
1673
+ * source from output, so a config-file `global: true` is dropped (unless
1674
+ * the caller also explicitly passes `global`); a warning is emitted in
1570
1675
  * that case. Returns the resolved boolean `global`.
1571
1676
  */
1572
1677
  function resolveGlobal({ logger, resolvedInputRoot, global, configByFile, validatedConfigPath }) {
1573
- if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an inputRoot was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
1678
+ if (resolvedInputRoot !== void 0 && global === void 0 && configByFile.global === true) warnWithFallback(logger, `Ignoring "global: true" from ${JSON.stringify(validatedConfigPath)} because an input root was configured; pass global=true (CLI: --global) to keep user-scope output. Output will be project-scope (global=false).`);
1574
1679
  return pick({
1575
1680
  cli: global,
1576
1681
  file: resolvedInputRoot !== void 0 ? false : configByFile.global,
@@ -1594,17 +1699,81 @@ function resolveFeaturesAndTargets({ features, targets, configByFile }) {
1594
1699
  resolvedTargets: userProvidedTargets ?? getDefaults().targets
1595
1700
  };
1596
1701
  }
1702
+ /**
1703
+ * Resolve the effective, non-empty, absolute-path list of source-tree roots
1704
+ * by applying CLI > file > default precedence and preferring `inputRoots`
1705
+ * over `inputRoot` when both survive the base+local merge. Duplicates (after
1706
+ * normalization to absolute paths) are removed silently so overlapping
1707
+ * base/local declarations do not double-count the same tree.
1708
+ *
1709
+ * Semantics (post-refactor):
1710
+ * - `inputRoots` entries are the source trees themselves (each holds
1711
+ * `rules/`, `skills/`, `mcp.jsonc`, etc.); they are passed through
1712
+ * unchanged.
1713
+ * - `inputRoot` (legacy singular) is a shorthand for "parent of the
1714
+ * `.rulesync/` source tree" and is expanded to `join(inputRoot,
1715
+ * ".rulesync")` before it hits any consumer.
1716
+ * - The "nothing configured" default expands to `[join(cwd, ".rulesync")]`
1717
+ * so existing projects keep working unchanged.
1718
+ *
1719
+ * When both the merged file config has `inputRoots` and the CLI supplied
1720
+ * `inputRoot` (or vice versa), CLI wins outright — matching how every
1721
+ * other field is resolved. When only the file config supplies both, the
1722
+ * plural wins over the singular and the drop is logged at debug level.
1723
+ */
1724
+ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile, cwd, logger }) {
1725
+ let source;
1726
+ let field;
1727
+ if (cliInputRoots !== void 0 && cliInputRoots.length > 0) {
1728
+ source = cliInputRoots;
1729
+ field = "inputRoots";
1730
+ } else if (cliInputRoot !== void 0) {
1731
+ source = [(0, node_path.join)(cliInputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1732
+ field = "inputRoot";
1733
+ } else if (configByFile.inputRoots !== void 0 && configByFile.inputRoots.length > 0) {
1734
+ source = configByFile.inputRoots;
1735
+ field = "inputRoots";
1736
+ 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.`);
1737
+ } else if (configByFile.inputRoot !== void 0) {
1738
+ source = [(0, node_path.join)(configByFile.inputRoot, RULESYNC_RELATIVE_DIR_PATH)];
1739
+ field = "inputRoot";
1740
+ } else source = [(0, node_path.join)(cwd, RULESYNC_RELATIVE_DIR_PATH)];
1741
+ const candidates = source.map((entry) => (0, node_path.resolve)(cwd, entry));
1742
+ const seen = /* @__PURE__ */ new Set();
1743
+ const resolved = [];
1744
+ for (const absolute of candidates) {
1745
+ if (seen.has(absolute)) continue;
1746
+ seen.add(absolute);
1747
+ resolved.push(absolute);
1748
+ }
1749
+ return {
1750
+ inputRoots: [resolved[0], ...resolved.slice(1)],
1751
+ candidates,
1752
+ field
1753
+ };
1754
+ }
1597
1755
  var ConfigResolver = class {
1598
- static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot }, { logger } = {}) {
1756
+ static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
1599
1757
  const cwd = (0, node_path.resolve)(process.cwd());
1758
+ assertInputRootFieldsExclusive({
1759
+ inputRoot,
1760
+ inputRoots
1761
+ });
1762
+ assertInputRootsNonEmpty({ inputRoots });
1600
1763
  if (inputRoot !== void 0) validateOutputRoot(inputRoot);
1601
- const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(inputRoot ?? cwd));
1764
+ if (inputRoots !== void 0) for (const entry of inputRoots) validateOutputRoot(entry);
1765
+ const cliConfigAnchor = inputRoot;
1766
+ const hasCliInputRootOverride = inputRoot !== void 0 || inputRoots !== void 0;
1767
+ const validatedConfigPath = resolvePath(configPath, (0, node_path.resolve)(cliConfigAnchor ?? cwd));
1602
1768
  const baseConfig = await loadConfigFromFile(validatedConfigPath);
1603
1769
  const configDir = (0, node_path.dirname)(validatedConfigPath);
1604
1770
  const localConfigPath = (0, node_path.join)(configDir, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
1605
1771
  const localConfig = await loadConfigFromFile(localConfigPath);
1606
1772
  const configByFile = mergeConfigs(baseConfig, localConfig);
1607
- if (inputRoot === void 0 && configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1773
+ if (!hasCliInputRootOverride) {
1774
+ if (configByFile.inputRoot !== void 0) validateOutputRoot(configByFile.inputRoot);
1775
+ if (configByFile.inputRoots !== void 0) for (const entry of configByFile.inputRoots) validateOutputRoot(entry);
1776
+ }
1608
1777
  assertMergedTargetsFeaturesExclusive({
1609
1778
  configByFile,
1610
1779
  validatedConfigPath,
@@ -1630,10 +1799,16 @@ var ConfigResolver = class {
1630
1799
  silent: resolvedSilent
1631
1800
  });
1632
1801
  }
1633
- const resolvedInputRoot = inputRoot ?? configByFile.inputRoot;
1802
+ const resolvedInputRoots = resolveEffectiveInputRoots({
1803
+ cliInputRoot: inputRoot,
1804
+ cliInputRoots: inputRoots,
1805
+ configByFile,
1806
+ cwd,
1807
+ logger
1808
+ }).inputRoots;
1634
1809
  const resolvedGlobal = resolveGlobal({
1635
1810
  logger,
1636
- resolvedInputRoot,
1811
+ resolvedInputRoot: inputRoot !== void 0 || inputRoots !== void 0 || configByFile.inputRoot !== void 0 || configByFile.inputRoots !== void 0 ? resolvedInputRoots[0] : void 0,
1637
1812
  global,
1638
1813
  configByFile,
1639
1814
  validatedConfigPath
@@ -1697,7 +1872,7 @@ var ConfigResolver = class {
1697
1872
  file: configByFile.check,
1698
1873
  fallback: getDefaults().check
1699
1874
  }),
1700
- inputRoot: resolvedInputRoot !== void 0 ? (0, node_path.resolve)(resolvedInputRoot) : cwd,
1875
+ inputRoots: resolvedInputRoots,
1701
1876
  configFilePath: validatedConfigPath,
1702
1877
  sources: configByFile.sources ?? getDefaults().sources,
1703
1878
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
@@ -2097,8 +2272,9 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2097
2272
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2098
2273
  };
2099
2274
  }
2100
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2101
- const filePath = (0, node_path.join)(outputRoot, RULESYNC_CHECKS_RELATIVE_DIR_PATH, relativeFilePath);
2275
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2276
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
2277
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
2102
2278
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
2103
2279
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
2104
2280
  const result = RulesyncCheckFrontmatterSchema.safeParse(frontmatter);
@@ -2106,7 +2282,7 @@ var RulesyncCheck = class RulesyncCheck extends RulesyncFile {
2106
2282
  const filename = (0, node_path.basename)(relativeFilePath);
2107
2283
  return new RulesyncCheck({
2108
2284
  outputRoot,
2109
- relativeDirPath: this.getSettablePaths().relativeDirPath,
2285
+ relativeDirPath: dirPath,
2110
2286
  relativeFilePath: filename,
2111
2287
  frontmatter: result.data,
2112
2288
  body: content.trim()
@@ -2177,8 +2353,9 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2177
2353
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
2178
2354
  };
2179
2355
  }
2180
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
2181
- const filePath = (0, node_path.join)(outputRoot, RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath);
2356
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
2357
+ const dirPath = relativeDirPath ?? RulesyncCommand.getSettablePaths().relativeDirPath;
2358
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
2182
2359
  const fileContent = await readFileContent(filePath);
2183
2360
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(fileContent, filePath);
2184
2361
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
@@ -2186,7 +2363,7 @@ var RulesyncCommand = class RulesyncCommand extends RulesyncFile {
2186
2363
  if (!result.success) throw new Error(`Invalid frontmatter in ${relativeFilePath}: ${formatError(result.error)}`);
2187
2364
  return new RulesyncCommand({
2188
2365
  outputRoot,
2189
- relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
2366
+ relativeDirPath: dirPath,
2190
2367
  relativeFilePath,
2191
2368
  frontmatter: result.data,
2192
2369
  body: content.trim(),
@@ -3575,8 +3752,19 @@ const CANONICAL_TO_VIBE_EVENT_NAMES = {
3575
3752
  };
3576
3753
  /**
3577
3754
  * Map Mistral Vibe snake_case event names to canonical camelCase.
3755
+ *
3756
+ * The pre-2.21.0 spellings are accepted alongside the current ones. Vibe's
3757
+ * strict `HookType` enum rejects a file that still uses them, so such a file is
3758
+ * already dead on disk; reading it here and emitting the renamed spelling is
3759
+ * what repairs it, whereas leaving the old name unmapped would route the hook
3760
+ * into a tool override block and lose the event.
3578
3761
  */
3579
- const VIBE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k]));
3762
+ const VIBE_TO_CANONICAL_EVENT_NAMES = {
3763
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_VIBE_EVENT_NAMES).map(([k, v]) => [v, k])),
3764
+ before_tool: "preToolUse",
3765
+ after_tool: "postToolUse",
3766
+ post_agent_turn: "stop"
3767
+ };
3580
3768
  /**
3581
3769
  * Map canonical camelCase event names to Qwen Code PascalCase.
3582
3770
  *
@@ -3797,15 +3985,17 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
3797
3985
  error: null
3798
3986
  };
3799
3987
  }
3800
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
3988
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
3801
3989
  const paths = RulesyncHooks.getSettablePaths();
3990
+ const overrideDirPath = relativeDirPath;
3802
3991
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
3803
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
3992
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
3993
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
3804
3994
  if (!await fileExists(filePath)) continue;
3805
3995
  const fileContent = await readFileContent(filePath);
3806
3996
  return new RulesyncHooks({
3807
3997
  outputRoot,
3808
- relativeDirPath: candidate.relativeDirPath,
3998
+ relativeDirPath: candidateDirPath,
3809
3999
  relativeFilePath: candidate.relativeFilePath,
3810
4000
  fileContent,
3811
4001
  validate
@@ -3832,21 +4022,23 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3832
4022
  relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
3833
4023
  relativeFilePath: RULESYNC_AIIGNORE_FILE_NAME
3834
4024
  },
3835
- legacy: {
4025
+ legacy: [{
3836
4026
  relativeDirPath: ".",
3837
4027
  relativeFilePath: RULESYNC_IGNORE_RELATIVE_FILE_PATH
3838
- }
4028
+ }]
3839
4029
  };
3840
4030
  }
3841
- static async fromFile({ outputRoot = process.cwd() } = {}) {
4031
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath } = {}) {
3842
4032
  const paths = this.getSettablePaths();
3843
- const recommendedPath = (0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
3844
- const legacyPath = (0, node_path.join)(outputRoot, paths.legacy.relativeDirPath, paths.legacy.relativeFilePath);
4033
+ const recommendedDirPath = relativeDirPath ?? paths.recommended.relativeDirPath;
4034
+ const recommendedPath = (0, node_path.join)(outputRoot, recommendedDirPath, paths.recommended.relativeFilePath);
4035
+ const [legacy] = paths.legacy;
4036
+ const legacyPath = (0, node_path.join)(outputRoot, legacy.relativeDirPath, legacy.relativeFilePath);
3845
4037
  if (await fileExists(recommendedPath)) {
3846
4038
  const fileContent = await readFileContent(recommendedPath);
3847
4039
  return new RulesyncIgnore({
3848
4040
  outputRoot,
3849
- relativeDirPath: paths.recommended.relativeDirPath,
4041
+ relativeDirPath: recommendedDirPath,
3850
4042
  relativeFilePath: paths.recommended.relativeFilePath,
3851
4043
  fileContent
3852
4044
  });
@@ -3855,15 +4047,15 @@ var RulesyncIgnore = class RulesyncIgnore extends RulesyncFile {
3855
4047
  const fileContent = await readFileContent(legacyPath);
3856
4048
  return new RulesyncIgnore({
3857
4049
  outputRoot,
3858
- relativeDirPath: paths.legacy.relativeDirPath,
3859
- relativeFilePath: paths.legacy.relativeFilePath,
4050
+ relativeDirPath: legacy.relativeDirPath,
4051
+ relativeFilePath: legacy.relativeFilePath,
3860
4052
  fileContent
3861
4053
  });
3862
4054
  }
3863
4055
  const fileContent = await readFileContent(recommendedPath);
3864
4056
  return new RulesyncIgnore({
3865
4057
  outputRoot,
3866
- relativeDirPath: paths.recommended.relativeDirPath,
4058
+ relativeDirPath: recommendedDirPath,
3867
4059
  relativeFilePath: paths.recommended.relativeFilePath,
3868
4060
  fileContent
3869
4061
  });
@@ -4019,6 +4211,118 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
4019
4211
  warp: zod_mini.z.optional(toolScopedMcpSchema),
4020
4212
  zed: zod_mini.z.optional(toolScopedMcpSchema)
4021
4213
  });
4214
+ /**
4215
+ * The tool-scoped block keys that carry a `{toolname}.mcpServers` sub-map.
4216
+ * Derived from `RulesyncMcpFileSchema`'s own shape so this set can never drift
4217
+ * from the schema — every tool-scoped block declared above is treated as a
4218
+ * "merge servers by name" site by `mergeMcpJsonOverlays`, and everything else
4219
+ * (including `$schema` and top-level Kimi Code timeout fields) is replaced
4220
+ * atomically.
4221
+ */
4222
+ const TOOL_SCOPED_MCP_KEYS = new Set(Object.keys(RulesyncMcpFileSchema.def.shape).filter((key) => key !== "$schema" && key !== "mcpServers"));
4223
+ /**
4224
+ * Return the first candidate path (recommended, then legacy variants) that
4225
+ * exists under `outputRoot`, or `undefined` when none is present. Shared
4226
+ * between `fromFile` (single-root) and `fromRoots` (multi-root) so both
4227
+ * paths honour the same intra-root resolution order.
4228
+ *
4229
+ * When `overrideDirPath` is provided it replaces the candidates'
4230
+ * class-level `relativeDirPath` (which defaults to `.rulesync/`) so the
4231
+ * caller can point at a non-default source tree (e.g. `.rulesync.local/`).
4232
+ * Also returned is the effective `relativeDirPath` for the winning
4233
+ * candidate so the caller can reconstruct a `RulesyncMcp` with matching
4234
+ * anchor fields.
4235
+ */
4236
+ async function findFirstExistingCandidate({ paths, outputRoot, overrideDirPath }) {
4237
+ for (const candidate of getRulesyncSourceCandidates({ paths })) {
4238
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4239
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
4240
+ if (await fileExists(filePath)) return {
4241
+ filePath,
4242
+ candidate: {
4243
+ relativeDirPath: candidateDirPath,
4244
+ relativeFilePath: candidate.relativeFilePath
4245
+ }
4246
+ };
4247
+ }
4248
+ }
4249
+ /**
4250
+ * Merge two parsed MCP JSON objects with the one-level policy from the
4251
+ * inputRoots plan: the top-level `mcpServers` map and each
4252
+ * `<toolname>.mcpServers` sub-map are merged by server name (later wins per
4253
+ * key). Every other value — individual server configs, other top-level keys
4254
+ * — is replaced atomically. This keeps the merge predictable: an overlay can
4255
+ * add or replace whole shared servers, but a partial patch of one server's
4256
+ * `args`/`env` is deliberately not supported.
4257
+ */
4258
+ function getRecordField({ value, path }) {
4259
+ if (value === void 0) return {};
4260
+ if (!isRecord$1(value)) throw new Error(`Invalid MCP overlay: '${path}' must be an object.`);
4261
+ return value;
4262
+ }
4263
+ /**
4264
+ * Overlay one record onto another, dropping any overlay key that could reach
4265
+ * `Object.prototype`. Every overlay merge goes through this so a `__proto__`
4266
+ * entry cannot enter the merged config from any depth — top-level
4267
+ * `mcpServers`, a tool-scoped block, or that block's own `mcpServers`.
4268
+ */
4269
+ function mergeRecordsSkippingPollutionKeys({ base, overlay }) {
4270
+ const merged = { ...base };
4271
+ for (const [key, value] of Object.entries(overlay)) {
4272
+ if (isPrototypePollutionKey(key)) continue;
4273
+ merged[key] = value;
4274
+ }
4275
+ return merged;
4276
+ }
4277
+ function mergeMcpJsonOverlays({ base, overlay }) {
4278
+ const merged = { ...base };
4279
+ for (const [key, overlayValue] of Object.entries(overlay)) {
4280
+ if (isPrototypePollutionKey(key)) continue;
4281
+ if (key === "mcpServers") {
4282
+ merged.mcpServers = mergeRecordsSkippingPollutionKeys({
4283
+ base: getRecordField({
4284
+ value: base.mcpServers,
4285
+ path: "mcpServers"
4286
+ }),
4287
+ overlay: getRecordField({
4288
+ value: overlayValue,
4289
+ path: "mcpServers"
4290
+ })
4291
+ });
4292
+ continue;
4293
+ }
4294
+ if (TOOL_SCOPED_MCP_KEYS.has(key)) {
4295
+ const baseBlock = getRecordField({
4296
+ value: base[key],
4297
+ path: key
4298
+ });
4299
+ const overlayBlock = getRecordField({
4300
+ value: overlayValue,
4301
+ path: key
4302
+ });
4303
+ const mergedBlock = mergeRecordsSkippingPollutionKeys({
4304
+ base: baseBlock,
4305
+ overlay: overlayBlock
4306
+ });
4307
+ const baseServers = getRecordField({
4308
+ value: baseBlock.mcpServers,
4309
+ path: `${key}.mcpServers`
4310
+ });
4311
+ const overlayServers = getRecordField({
4312
+ value: overlayBlock.mcpServers,
4313
+ path: `${key}.mcpServers`
4314
+ });
4315
+ if (Object.keys(baseServers).length > 0 || Object.keys(overlayServers).length > 0) mergedBlock.mcpServers = mergeRecordsSkippingPollutionKeys({
4316
+ base: baseServers,
4317
+ overlay: overlayServers
4318
+ });
4319
+ merged[key] = mergedBlock;
4320
+ continue;
4321
+ }
4322
+ merged[key] = overlayValue;
4323
+ }
4324
+ return merged;
4325
+ }
4022
4326
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4023
4327
  json;
4024
4328
  constructor(params) {
@@ -4055,28 +4359,136 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4055
4359
  error: null
4056
4360
  };
4057
4361
  }
4058
- static async fromFile({ outputRoot = process.cwd(), validate = true, logger }) {
4362
+ /**
4363
+ * Load and merge MCP source files across the configured input roots.
4364
+ *
4365
+ * `inputRoots` entries are the source trees themselves (e.g.
4366
+ * `/repo/.rulesync`, `/repo/.rulesync.local`). Per-root behavior mirrors
4367
+ * `fromFile`: each root's own candidate paths (recommended `mcp.jsonc`,
4368
+ * legacy `mcp.json`, deprecated `.mcp.json`) are checked INSIDE that
4369
+ * source tree and the first hit is loaded. Roots that have no candidate
4370
+ * contribute nothing.
4371
+ *
4372
+ * Cross-root behavior: the parsed JSON objects are folded left-to-right
4373
+ * with `mergeMcpJsonOverlays`, so later roots overlay earlier ones by
4374
+ * server name (one level deep) and replace every other value atomically.
4375
+ * With one root, this delegates to `fromFile` so JSONC formatting and the
4376
+ * actual candidate path are preserved. With multiple roots, each source is
4377
+ * parsed and schema-validated before merging so failures name the originating
4378
+ * file. The merged object is necessarily synthetic, serialized JSON anchored
4379
+ * to the first root's recommended path.
4380
+ *
4381
+ * A multi-root configuration where only one root actually supplies a file is
4382
+ * treated as the single-root case: nothing is merged, so the original file
4383
+ * content is kept verbatim (preserving JSONC comments) and the instance is
4384
+ * anchored at the root that supplied it rather than at the primary root's
4385
+ * recommended path.
4386
+ *
4387
+ * When no root supplies any candidate, this falls back to reading the
4388
+ * primary root's recommended path so the underlying file-not-found error
4389
+ * matches the single-root behavior of `fromFile`.
4390
+ */
4391
+ static async fromRoots({ inputRoots, validate = true, logger }) {
4392
+ if (inputRoots.length === 1) {
4393
+ const [primary] = inputRoots;
4394
+ return this.fromFile({
4395
+ outputRoot: (0, node_path.dirname)(primary),
4396
+ relativeDirPath: (0, node_path.basename)(primary),
4397
+ validate,
4398
+ logger
4399
+ });
4400
+ }
4059
4401
  const paths = this.getSettablePaths();
4402
+ const rootSources = [];
4403
+ for (const root of inputRoots) {
4404
+ const parent = (0, node_path.dirname)(root);
4405
+ const treeName = (0, node_path.basename)(root);
4406
+ const found = await findFirstExistingCandidate({
4407
+ paths,
4408
+ outputRoot: parent,
4409
+ overrideDirPath: treeName
4410
+ });
4411
+ if (found === void 0) continue;
4412
+ const { filePath, candidate } = found;
4413
+ if (filePath.endsWith(".mcp.json")) {
4414
+ const recommendedPath = (0, node_path.join)(parent, treeName, paths.recommended.relativeFilePath);
4415
+ logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4416
+ }
4417
+ const fileContent = await readFileContent(filePath);
4418
+ let parsed;
4419
+ try {
4420
+ parsed = parseJsonc$8(fileContent);
4421
+ if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
4422
+ if (validate) {
4423
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
4424
+ if (!result.success) throw result.error;
4425
+ }
4426
+ } catch (error) {
4427
+ throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
4428
+ }
4429
+ rootSources.push({
4430
+ record: parsed,
4431
+ outputRoot: parent,
4432
+ relativeDirPath: candidate.relativeDirPath,
4433
+ relativeFilePath: candidate.relativeFilePath,
4434
+ fileContent
4435
+ });
4436
+ }
4437
+ if (rootSources.length === 0) {
4438
+ const primary = inputRoots[0];
4439
+ return this.fromFile({
4440
+ outputRoot: (0, node_path.dirname)(primary),
4441
+ relativeDirPath: (0, node_path.basename)(primary),
4442
+ validate,
4443
+ logger
4444
+ });
4445
+ }
4446
+ const onlySource = rootSources.length === 1 ? rootSources[0] : void 0;
4447
+ if (onlySource !== void 0) return new RulesyncMcp({
4448
+ outputRoot: onlySource.outputRoot,
4449
+ relativeDirPath: onlySource.relativeDirPath,
4450
+ relativeFilePath: onlySource.relativeFilePath,
4451
+ fileContent: onlySource.fileContent,
4452
+ validate
4453
+ });
4454
+ const merged = rootSources.reduce((acc, next) => mergeMcpJsonOverlays({
4455
+ base: acc,
4456
+ overlay: next.record
4457
+ }), {});
4458
+ const primary = inputRoots[0];
4459
+ return new RulesyncMcp({
4460
+ outputRoot: (0, node_path.dirname)(primary),
4461
+ relativeDirPath: (0, node_path.basename)(primary),
4462
+ relativeFilePath: paths.recommended.relativeFilePath,
4463
+ fileContent: JSON.stringify(merged, null, 2),
4464
+ validate
4465
+ });
4466
+ }
4467
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true, logger }) {
4468
+ const paths = this.getSettablePaths();
4469
+ const overrideDirPath = relativeDirPath;
4060
4470
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
4061
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
4471
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
4472
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
4062
4473
  if (!await fileExists(filePath)) continue;
4063
4474
  if (candidate.relativeFilePath === ".mcp.json") {
4064
- const recommendedPath = (0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath);
4475
+ const recommendedPath = (0, node_path.join)(outputRoot, candidateDirPath, paths.recommended.relativeFilePath);
4065
4476
  logger?.warn(`⚠️ Using deprecated path "${filePath}". Please migrate to "${recommendedPath}"`);
4066
4477
  }
4067
4478
  const fileContent = await readFileContent(filePath);
4068
4479
  return new RulesyncMcp({
4069
4480
  outputRoot,
4070
- relativeDirPath: candidate.relativeDirPath,
4481
+ relativeDirPath: candidateDirPath,
4071
4482
  relativeFilePath: candidate.relativeFilePath,
4072
4483
  fileContent,
4073
4484
  validate
4074
4485
  });
4075
4486
  }
4076
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.recommended.relativeDirPath, paths.recommended.relativeFilePath));
4487
+ const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
4488
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath));
4077
4489
  return new RulesyncMcp({
4078
4490
  outputRoot,
4079
- relativeDirPath: paths.recommended.relativeDirPath,
4491
+ relativeDirPath: fallbackDirPath,
4080
4492
  relativeFilePath: paths.recommended.relativeFilePath,
4081
4493
  fileContent,
4082
4494
  validate
@@ -5271,15 +5683,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5271
5683
  error: null
5272
5684
  };
5273
5685
  }
5274
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5686
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5275
5687
  const paths = RulesyncPermissions.getSettablePaths();
5688
+ const overrideDirPath = relativeDirPath;
5276
5689
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5277
- const filePath = (0, node_path.join)(outputRoot, candidate.relativeDirPath, candidate.relativeFilePath);
5690
+ const candidateDirPath = overrideDirPath ?? candidate.relativeDirPath;
5691
+ const filePath = (0, node_path.join)(outputRoot, candidateDirPath, candidate.relativeFilePath);
5278
5692
  if (!await fileExists(filePath)) continue;
5279
5693
  const fileContent = await readFileContent(filePath);
5280
5694
  return new RulesyncPermissions({
5281
5695
  outputRoot,
5282
- relativeDirPath: candidate.relativeDirPath,
5696
+ relativeDirPath: candidateDirPath,
5283
5697
  relativeFilePath: candidate.relativeFilePath,
5284
5698
  fileContent,
5285
5699
  validate
@@ -5443,8 +5857,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5443
5857
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5444
5858
  };
5445
5859
  }
5446
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5447
- const filePath = (0, node_path.join)(outputRoot, this.getSettablePaths().recommended.relativeDirPath, relativeFilePath);
5860
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
5861
+ const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
5862
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
5448
5863
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5449
5864
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5450
5865
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5457,7 +5872,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5457
5872
  };
5458
5873
  return new RulesyncRule({
5459
5874
  outputRoot,
5460
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
5875
+ relativeDirPath: dirPath,
5461
5876
  relativeFilePath,
5462
5877
  frontmatter: validatedFrontmatter,
5463
5878
  body: content.trim(),
@@ -5705,7 +6120,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5705
6120
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5706
6121
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5707
6122
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
5708
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
6123
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
6124
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
6125
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
6126
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
6127
+ version: zod_mini.z.optional(zod_mini.z.unknown())
5709
6128
  })),
5710
6129
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
5711
6130
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -5881,8 +6300,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5881
6300
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5882
6301
  };
5883
6302
  }
5884
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5885
- const filePath = (0, node_path.join)(outputRoot, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, relativeFilePath);
6303
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
6304
+ const dirPath = relativeDirPath ?? this.getSettablePaths().relativeDirPath;
6305
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
5886
6306
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5887
6307
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5888
6308
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5890,7 +6310,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5890
6310
  const filename = (0, node_path.basename)(relativeFilePath);
5891
6311
  return new RulesyncSubagent({
5892
6312
  outputRoot,
5893
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6313
+ relativeDirPath: dirPath,
5894
6314
  relativeFilePath: filename,
5895
6315
  frontmatter: result.data,
5896
6316
  body: content.trim()
@@ -5900,16 +6320,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5900
6320
  //#endregion
5901
6321
  //#region src/features/skills/skills-utils.ts
5902
6322
  /**
5903
- * Returns the set of local skill directory names (excluding `.curated`).
6323
+ * Returns the set of local skill directory names (excluding `.curated`)
6324
+ * from a rulesync source tree (e.g. `/repo/.rulesync` or
6325
+ * `/repo/.rulesync.local`).
5904
6326
  */
5905
- async function getLocalSkillDirNames(outputRoot) {
5906
- const skillsDir = (0, node_path.join)(outputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
6327
+ async function getLocalSkillDirNames(sourceTree) {
6328
+ const skillsDir = (0, node_path.join)(sourceTree, SKILLS_FEATURE_SUBDIR);
5907
6329
  const names = /* @__PURE__ */ new Set();
5908
6330
  if (!await directoryExists(skillsDir)) return names;
5909
6331
  const dirPaths = await findFilesByGlobs((0, node_path.join)(skillsDir, "*"), { type: "dir" });
5910
6332
  for (const dirPath of dirPaths) {
5911
6333
  const name = (0, node_path.basename)(dirPath);
5912
- if (name === (0, node_path.basename)(RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH)) continue;
6334
+ if (name === (0, node_path.basename)(CURATED_SKILLS_FEATURE_SUBDIR)) continue;
5913
6335
  names.add(name);
5914
6336
  }
5915
6337
  return names;
@@ -6222,15 +6644,49 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6222
6644
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6223
6645
  }
6224
6646
  //#endregion
6647
+ //#region src/utils/control-characters.ts
6648
+ /**
6649
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
6650
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
6651
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
6652
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
6653
+ * settings file must never reach the terminal with these intact: they let the
6654
+ * text forge log lines, reorder what is printed around them, or inject escape
6655
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
6656
+ * neutral characters beside them, so they go too — a diagnostic line is not the
6657
+ * place to preserve the typography of a right-to-left name.
6658
+ */
6659
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
6660
+ /**
6661
+ * Removes every control character from `text` so it is safe to splice into a
6662
+ * log line or other terminal output.
6663
+ */
6664
+ function stripControlCharacters(text) {
6665
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
6666
+ }
6667
+ //#endregion
6225
6668
  //#region src/types/feature-processor.ts
6226
6669
  var FeatureProcessor = class {
6227
6670
  outputRoot;
6228
- inputRoot;
6671
+ /**
6672
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
6673
+ * is a source tree itself — the directory that directly contains feature
6674
+ * subdirectories (`rules/`, `commands/`, …) and single-file features
6675
+ * (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence in
6676
+ * per-feature merges. Defaults to `[join(process.cwd(), ".rulesync")]`.
6677
+ *
6678
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
6679
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
6680
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
6681
+ * reaches a processor — every internal consumer only ever sees the
6682
+ * plural form, with the source tree already resolved.
6683
+ */
6684
+ inputRoots;
6229
6685
  dryRun;
6230
6686
  logger;
6231
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
6687
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6232
6688
  this.outputRoot = outputRoot;
6233
- this.inputRoot = inputRoot;
6689
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
6234
6690
  this.dryRun = dryRun;
6235
6691
  this.logger = logger;
6236
6692
  }
@@ -6297,6 +6753,198 @@ var FeatureProcessor = class {
6297
6753
  return orphanFiles.length;
6298
6754
  }
6299
6755
  };
6756
+ /**
6757
+ * Messages already reported for a given logger.
6758
+ *
6759
+ * One `generate` run constructs a single-file processor per tool target and
6760
+ * per output root — more than twenty times for `--targets "*"` — and each one
6761
+ * re-resolves the same roots. Keying on the logger — created once per run, and
6762
+ * once per test — keeps the shadowing warning to a single line instead of
6763
+ * repeating it for every target. `--watch` reuses one logger across runs, so
6764
+ * {@link resetRootShadowingWarnings} clears the set at the start of each one.
6765
+ */
6766
+ const warnedRootShadowingByLogger = /* @__PURE__ */ new WeakMap();
6767
+ /**
6768
+ * Forget which shadowing warnings have already been reported.
6769
+ *
6770
+ * `generate` calls this once per run. Without it, `--watch` reuses one logger
6771
+ * for the whole session, so the warning would be printed on the first
6772
+ * generation and never again — the opposite of why it is a warning, since
6773
+ * `--watch` is exactly when an overlay is most likely to be added or edited.
6774
+ */
6775
+ function resetRootShadowingWarnings({ logger }) {
6776
+ warnedRootShadowingByLogger.delete(logger);
6777
+ }
6778
+ /**
6779
+ * Return the last input root that contains any of the given `relativePaths`,
6780
+ * or `undefined` when none of the roots has any of them. Used by single-file
6781
+ * features (hooks, permissions, ignore) to implement the "later root wins
6782
+ * the whole file" merge policy without materializing the file contents.
6783
+ *
6784
+ * `relativePaths` accepts a small list so features that historically read
6785
+ * either a recommended path or a legacy alias (e.g. `.rulesync/mcp.jsonc`
6786
+ * plus `.rulesync/mcp.json`) can preserve that resolution order per root.
6787
+ * A root counts as "having" the file as long as at least one candidate path
6788
+ * is present.
6789
+ */
6790
+ async function pickLastRootWithFile({ inputRoots, relativePaths, logger, artifactName }) {
6791
+ let winner;
6792
+ const rootsWithFile = [];
6793
+ for (const root of inputRoots) for (const relativePath of relativePaths) if (await fileExists((0, node_path.join)(root, relativePath))) {
6794
+ winner = root;
6795
+ rootsWithFile.push(root);
6796
+ break;
6797
+ }
6798
+ if (rootsWithFile.length > 1 && winner !== void 0) {
6799
+ const shadowed = rootsWithFile.slice(0, -1);
6800
+ const message = `${artifactName} is provided by more than one input root; '${stripControlCharacters(winner)}' replaces the whole file from ${shadowed.map((root) => `'${stripControlCharacters(root)}'`).join(", ")}.`;
6801
+ let warnedMessages = warnedRootShadowingByLogger.get(logger);
6802
+ if (warnedMessages === void 0) {
6803
+ warnedMessages = /* @__PURE__ */ new Set();
6804
+ warnedRootShadowingByLogger.set(logger, warnedMessages);
6805
+ }
6806
+ if (!warnedMessages.has(message)) {
6807
+ warnedMessages.add(message);
6808
+ logger.warn(message);
6809
+ }
6810
+ }
6811
+ return winner;
6812
+ }
6813
+ /**
6814
+ * Merge per-root result lists into a single ordered list, keeping the
6815
+ * later root's entry when two roots produced an item with the same
6816
+ * identity. Identity is intentionally provided by the caller so
6817
+ * per-feature nuances (case-insensitive filesystems, directory names for
6818
+ * skills, server names for MCP) live next to the feature that owns them.
6819
+ *
6820
+ * The returned list preserves the FIRST appearance order of each identity —
6821
+ * items in the earliest root keep their position, but their content is
6822
+ * replaced by the last root that provided the same identity. This matches
6823
+ * the "overlay" mental model: an overlay changes content, not order.
6824
+ */
6825
+ function mergeByIdentity({ perRoot, identity }) {
6826
+ const order = [];
6827
+ const winnerByKey = /* @__PURE__ */ new Map();
6828
+ for (const rootItems of perRoot) for (const item of rootItems) {
6829
+ const key = identity(item);
6830
+ if (!winnerByKey.has(key)) order.push(key);
6831
+ winnerByKey.set(key, item);
6832
+ }
6833
+ return order.map((key) => winnerByKey.get(key));
6834
+ }
6835
+ /**
6836
+ * The key two spellings share when a case-insensitive filesystem would give
6837
+ * them one file. `toLowerCase()` is locale-independent (unlike
6838
+ * `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
6839
+ * locale), and the NFC pass folds the composed and decomposed spellings of an
6840
+ * accented name — which macOS also resolves to a single directory —
6841
+ * onto each other.
6842
+ *
6843
+ * This is simple lowercasing rather than full Unicode case folding, so it is
6844
+ * deliberately narrower than what a filesystem considers one file: a Greek
6845
+ * final sigma, a Turkish `ı` under NTFS's upcasing, and a Win32 name whose
6846
+ * trailing dot is stripped all still produce distinct keys. Those pairs keep
6847
+ * the pre-existing behavior (both are imported, and the later one wins on the
6848
+ * filesystem); folding them here would instead drop names that a
6849
+ * case-sensitive filesystem keeps genuinely apart.
6850
+ */
6851
+ function caseFoldIdentity(identity) {
6852
+ return identity.normalize("NFC").toLowerCase();
6853
+ }
6854
+ /**
6855
+ * Group spellings by their case-folded identity, keeping every original
6856
+ * spelling. On a case-sensitive filesystem one identity can cover several
6857
+ * spellings at once, and the caller needs them all to describe a collision
6858
+ * accurately.
6859
+ */
6860
+ function groupSpellingsByCaseFoldedIdentity(spellings) {
6861
+ const grouped = /* @__PURE__ */ new Map();
6862
+ for (const spelling of spellings) {
6863
+ const identity = caseFoldIdentity(spelling);
6864
+ const existing = grouped.get(identity);
6865
+ if (existing === void 0) grouped.set(identity, [spelling]);
6866
+ else existing.push(spelling);
6867
+ }
6868
+ return grouped;
6869
+ }
6870
+ /**
6871
+ * Build the warning emitted when a `.curated/` entry and a local entry in the
6872
+ * same tree differ only in case.
6873
+ *
6874
+ * `.curated/` is expanded from a declarative source (an external Git repository
6875
+ * or npm package), so its names are untrusted input; both sides are stripped of
6876
+ * control characters before they reach the terminal.
6877
+ *
6878
+ * The winning local spelling is the LAST one, matching the precedence
6879
+ * {@link mergeByCaseInsensitiveIdentity} applies afterwards; any other spelling
6880
+ * that folds onto the same identity is listed too, so the message never names a
6881
+ * spelling that loses.
6882
+ */
6883
+ function formatCuratedCaseCollisionWarning({ artifactKind, entryNoun, treeDirPath, curatedSpelling, localSpellings }) {
6884
+ const winner = localSpellings[localSpellings.length - 1] ?? "";
6885
+ const shadowed = localSpellings.slice(0, -1);
6886
+ const shadowedSuffix = shadowed.length === 0 ? "" : ` Other local spellings that fold onto the same identity: ${shadowed.map((spelling) => `'${stripControlCharacters(spelling)}'`).join(", ")}.`;
6887
+ 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;
6888
+ }
6889
+ /**
6890
+ * Merge artifacts whose filenames are case-insensitive identities, warning
6891
+ * when distinct spellings collapse to the same key. Exact-name overlays are
6892
+ * intentional and remain quiet; only case-only ambiguity is diagnosed.
6893
+ *
6894
+ * Precedence here is the opposite of {@link ClaimedIdentities}: this merges
6895
+ * overlay roots, where the LAST entry wins, while the tool-side loaders keep
6896
+ * the FIRST root to claim a name.
6897
+ */
6898
+ function mergeByCaseInsensitiveIdentity({ perRoot, identity, artifactName, logger }) {
6899
+ const spellingByKey = /* @__PURE__ */ new Map();
6900
+ const warnedKeys = /* @__PURE__ */ new Set();
6901
+ return mergeByIdentity({
6902
+ perRoot,
6903
+ identity: (item) => {
6904
+ const spelling = identity(item);
6905
+ const key = caseFoldIdentity(spelling);
6906
+ const previousSpelling = spellingByKey.get(key);
6907
+ if (previousSpelling !== void 0 && previousSpelling !== spelling && !warnedKeys.has(key)) {
6908
+ logger.warn(`Case-insensitive ${artifactName} collision: '${stripControlCharacters(previousSpelling)}' and '${stripControlCharacters(spelling)}' resolve to the same identity. The later entry wins.`);
6909
+ warnedKeys.add(key);
6910
+ }
6911
+ if (previousSpelling === void 0) spellingByKey.set(key, spelling);
6912
+ return key;
6913
+ }
6914
+ });
6915
+ }
6916
+ /**
6917
+ * Tracks the import identities already claimed while scanning, folding case
6918
+ * through {@link caseFoldIdentity}.
6919
+ *
6920
+ * The tool-side loaders scan several roots in precedence order and keep the
6921
+ * first spelling of each identity. Comparing those identities exactly lets
6922
+ * `.junie/skills/dup-skill` and `.agents/skills/Dup-Skill` both through, and
6923
+ * since macOS and Windows resolve the two written-back directories to a
6924
+ * single one, the shared Agent Skills copy lands last and overwrites the
6925
+ * tool-specific one — inverting the precedence the roots were ordered by.
6926
+ *
6927
+ * The FIRST claimer wins, which is the opposite of the overlay precedence in
6928
+ * {@link mergeByCaseInsensitiveIdentity}: roots are passed in precedence
6929
+ * order, so the earliest one to name a skill is the one that should keep it.
6930
+ */
6931
+ var ClaimedIdentities = class {
6932
+ claimByKey = /* @__PURE__ */ new Map();
6933
+ /**
6934
+ * Claim `identity` on behalf of `source`. Returns `null` when nothing held
6935
+ * it yet, or the standing claim when something did.
6936
+ */
6937
+ claim({ identity, source }) {
6938
+ const key = caseFoldIdentity(identity);
6939
+ const claimed = this.claimByKey.get(key);
6940
+ if (claimed !== void 0) return claimed;
6941
+ this.claimByKey.set(key, {
6942
+ spelling: identity,
6943
+ source
6944
+ });
6945
+ return null;
6946
+ }
6947
+ };
6300
6948
  //#endregion
6301
6949
  //#region src/constants/amp-paths.ts
6302
6950
  const AMP_DIR = ".amp";
@@ -8619,10 +9267,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8619
9267
  toolTarget;
8620
9268
  global;
8621
9269
  getFactory;
8622
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
9270
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, logger }) {
8623
9271
  super({
8624
9272
  outputRoot,
8625
- inputRoot,
9273
+ inputRoots,
8626
9274
  dryRun,
8627
9275
  logger
8628
9276
  });
@@ -8659,11 +9307,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8659
9307
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8660
9308
  }
8661
9309
  /**
8662
- * Implementation of abstract method from Processor
8663
- * Load and parse rulesync check files from .rulesync/checks/ directory
9310
+ * Load check files from a single source-tree's `checks/` subtree.
9311
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
9312
+ * `/repo/.rulesync.local`).
8664
9313
  */
8665
- async loadRulesyncFiles() {
8666
- const checksDir = (0, node_path.join)(this.inputRoot, RulesyncCheck.getSettablePaths().relativeDirPath);
9314
+ async loadRulesyncFilesForRoot(sourceTree) {
9315
+ const treeParent = (0, node_path.dirname)(sourceTree);
9316
+ const treeName = (0, node_path.basename)(sourceTree);
9317
+ const treeChecksDirPath = (0, node_path.join)(treeName, CHECKS_FEATURE_SUBDIR);
9318
+ const checksDir = (0, node_path.join)(sourceTree, CHECKS_FEATURE_SUBDIR);
8667
9319
  if (!await directoryExists(checksDir)) {
8668
9320
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8669
9321
  return [];
@@ -8679,7 +9331,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8679
9331
  const filepath = (0, node_path.join)(checksDir, mdFile);
8680
9332
  try {
8681
9333
  const rulesyncCheck = await RulesyncCheck.fromFile({
8682
- outputRoot: this.inputRoot,
9334
+ outputRoot: treeParent,
9335
+ relativeDirPath: treeChecksDirPath,
8683
9336
  relativeFilePath: mdFile,
8684
9337
  validate: true
8685
9338
  });
@@ -8690,6 +9343,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8690
9343
  continue;
8691
9344
  }
8692
9345
  }
9346
+ return rulesyncChecks;
9347
+ }
9348
+ /**
9349
+ * Implementation of abstract method from Processor
9350
+ * Load and parse rulesync check files from every configured input root's
9351
+ * `.rulesync/checks/` directory, merging by relative file path so a check
9352
+ * from a later root replaces the earlier root's copy.
9353
+ */
9354
+ async loadRulesyncFiles() {
9355
+ const rulesyncChecks = mergeByCaseInsensitiveIdentity({
9356
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
9357
+ identity: (check) => check.getRelativeFilePath(),
9358
+ artifactName: "check",
9359
+ logger: this.logger
9360
+ });
8693
9361
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8694
9362
  return rulesyncChecks;
8695
9363
  }
@@ -10168,18 +10836,23 @@ function commandSlug(relativeFilePath) {
10168
10836
  return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10169
10837
  }
10170
10838
  /**
10171
- * Whether a rulesync command exists whose slug matches `dirName`.
10839
+ * Whether a rulesync command exists whose slug matches `dirName` in any of
10840
+ * the configured input roots.
10841
+ *
10842
+ * `inputRoots[i]` is a source tree itself (e.g. `/repo/.rulesync` or
10843
+ * `/repo/.rulesync.local`), so commands live directly under
10844
+ * `<sourceTree>/commands/`.
10172
10845
  *
10173
10846
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10174
10847
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10175
10848
  * current command slug is owned by the commands feature, so the skills
10176
10849
  * feature must neither import it as a skill nor delete it as an orphan
10177
- * skill. Once the command is removed from `.rulesync/commands/`, the
10178
- * directory stops matching and the skills feature cleans it up as a regular
10179
- * orphan.
10850
+ * skill. Once the command is removed from every source tree's `commands/`
10851
+ * directory, the directory stops matching and the skills feature cleans
10852
+ * it up as a regular orphan.
10180
10853
  */
10181
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10182
- return (await findFilesByGlobs((0, node_path.join)(inputRoot, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "**", "*.md"))).some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
10854
+ async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
10855
+ return (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug((0, node_path.basename)(filePath)) === dirName);
10183
10856
  }
10184
10857
  //#endregion
10185
10858
  //#region src/features/commands/devin-command.ts
@@ -10869,11 +11542,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10869
11542
  * output is lost — so this warns rather than failing the run the way the
10870
11543
  * Hermes check does, where the two surfaces really do write the same path.
10871
11544
  */
10872
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
11545
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10873
11546
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => (0, node_path.basename)(command.getRelativeFilePath(), ".md")));
10874
11547
  if (commandNames.size === 0) return;
10875
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
10876
- const shadowed = (await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"))).map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
11548
+ const shadowed = (await Promise.all(inputRoots.map((root) => findFilesByGlobs((0, node_path.join)(root, SKILLS_FEATURE_SUBDIR, "**", "SKILL.md"))))).flat().map((filePath) => (0, node_path.basename)((0, node_path.dirname)(filePath))).filter((skillName) => commandNames.has(skillName));
10877
11549
  if (shadowed.length > 0) logger.warn(`Grok CLI resolves skills before commands, so these skills shadow the same-named commands, which will never be reachable: ${[...new Set(shadowed)].toSorted().join(", ")}. Rename either side to make both invocable.`);
10878
11550
  }
10879
11551
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -11645,7 +12317,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11645
12317
  static getExtraSharedWritePaths() {
11646
12318
  return getHermesagentSharedConfigWritePaths();
11647
12319
  }
11648
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
12320
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11649
12321
  const commandSlugs = /* @__PURE__ */ new Set();
11650
12322
  const commandOrigins = /* @__PURE__ */ new Map();
11651
12323
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11657,13 +12329,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11657
12329
  commandOrigins.set(slug, origin);
11658
12330
  commandSlugs.add(slug);
11659
12331
  }
11660
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
11661
- const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
11662
- const collisions = (await Promise.all(skillFiles.map((path) => RulesyncSkill.fromDir({
11663
- outputRoot: inputRoot,
11664
- relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
11665
- dirName: toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(path)))
11666
- })))).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
12332
+ const skillsByName = /* @__PURE__ */ new Map();
12333
+ for (const rootPath of inputRoots) {
12334
+ const skillsRoot = (0, node_path.join)(rootPath, SKILLS_FEATURE_SUBDIR);
12335
+ const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
12336
+ const loaded = await Promise.all(skillFiles.map(async (filePath) => {
12337
+ const dirName = toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(filePath)));
12338
+ return {
12339
+ rulesyncSkill: await RulesyncSkill.fromDir({
12340
+ outputRoot: rootPath,
12341
+ relativeDirPath: SKILLS_FEATURE_SUBDIR,
12342
+ dirName
12343
+ }),
12344
+ dirName,
12345
+ rootPath
12346
+ };
12347
+ }));
12348
+ for (const entry of loaded) skillsByName.set(caseFoldIdentity(entry.dirName), entry);
12349
+ }
12350
+ const collisions = [...skillsByName.values()].map(({ rulesyncSkill }) => rulesyncSkill).filter((skill) => HermesagentSkill.isTargetedByRulesyncSkill(skill)).map((skill) => hermesSlashName(skill.getFrontmatter().name)).filter((slug) => commandSlugs.has(slug));
11667
12351
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11668
12352
  }
11669
12353
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11796,6 +12480,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11796
12480
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11797
12481
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11798
12482
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
12483
+ const JUNIE_RULES_DIR_NAME = "rules";
12484
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11799
12485
  //#endregion
11800
12486
  //#region src/features/commands/junie-command.ts
11801
12487
  const JunieCommandFrontmatterSchema = zod_mini.z.looseObject({ description: zod_mini.z.optional(zod_mini.z.string()) });
@@ -13756,10 +14442,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13756
14442
  global;
13757
14443
  getFactory;
13758
14444
  flattenedCommandNaming;
13759
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
14445
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$5, dryRun = false, flattenedCommandNaming = "basename", logger }) {
13760
14446
  super({
13761
14447
  outputRoot,
13762
- inputRoot,
14448
+ inputRoots,
13763
14449
  dryRun,
13764
14450
  logger
13765
14451
  });
@@ -13774,7 +14460,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13774
14460
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13775
14461
  const factory = this.getFactory(this.toolTarget);
13776
14462
  await factory.class.validateRulesyncCommands?.({
13777
- inputRoot: this.inputRoot,
14463
+ inputRoots: this.inputRoots,
13778
14464
  rulesyncCommands,
13779
14465
  logger: this.logger
13780
14466
  });
@@ -13826,16 +14512,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13826
14512
  return rel;
13827
14513
  }
13828
14514
  /**
13829
- * Implementation of abstract method from FeatureProcessor
13830
- * Load and parse rulesync command files from .rulesync/commands/ directory
14515
+ * Load rulesync command files from a single source-tree's `commands/`
14516
+ * subtree. `sourceTree` is the source tree itself (e.g.
14517
+ * `/repo/.rulesync` or `/repo/.rulesync.local`).
13831
14518
  */
13832
- async loadRulesyncFiles() {
13833
- const basePath = (0, node_path.join)(this.inputRoot, RulesyncCommand.getSettablePaths().relativeDirPath);
14519
+ async loadRulesyncFilesForRoot(sourceTree) {
14520
+ const treeParent = (0, node_path.dirname)(sourceTree);
14521
+ const treeName = (0, node_path.basename)(sourceTree);
14522
+ const treeCommandsDirPath = (0, node_path.join)(treeName, COMMANDS_FEATURE_SUBDIR);
14523
+ const basePath = (0, node_path.join)(sourceTree, COMMANDS_FEATURE_SUBDIR);
13834
14524
  const rulesyncCommandPaths = await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md"));
13835
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13836
- outputRoot: this.inputRoot,
14525
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
14526
+ outputRoot: treeParent,
14527
+ relativeDirPath: treeCommandsDirPath,
13837
14528
  relativeFilePath: this.safeRelativePath(basePath, path)
13838
14529
  })));
14530
+ }
14531
+ /**
14532
+ * Implementation of abstract method from FeatureProcessor
14533
+ * Load and parse rulesync command files from every configured input root's
14534
+ * `.rulesync/commands/` directory, merging by relative path so a command
14535
+ * with the same target path from a later root replaces the earlier root's
14536
+ * copy.
14537
+ */
14538
+ async loadRulesyncFiles() {
14539
+ const rulesyncCommands = mergeByCaseInsensitiveIdentity({
14540
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
14541
+ identity: (command) => command.getRelativeFilePath(),
14542
+ artifactName: "command",
14543
+ logger: this.logger
14544
+ });
13839
14545
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13840
14546
  return rulesyncCommands;
13841
14547
  }
@@ -19475,6 +20181,7 @@ function vibeEntryToCanonicalDef(raw) {
19475
20181
  const entry = raw;
19476
20182
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19477
20183
  if (vibeEvent === void 0) return null;
20184
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19478
20185
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19479
20186
  const def = { type: "command" };
19480
20187
  if (typeof entry.command === "string") def.command = entry.command;
@@ -20023,10 +20730,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
20023
20730
  var HooksProcessor = class extends FeatureProcessor {
20024
20731
  toolTarget;
20025
20732
  global;
20026
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
20733
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
20027
20734
  super({
20028
20735
  outputRoot,
20029
- inputRoot,
20736
+ inputRoots,
20030
20737
  dryRun,
20031
20738
  logger
20032
20739
  });
@@ -20036,9 +20743,17 @@ var HooksProcessor = class extends FeatureProcessor {
20036
20743
  this.global = global;
20037
20744
  }
20038
20745
  async loadRulesyncFiles() {
20746
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
20747
+ const sourceTree = await pickLastRootWithFile({
20748
+ inputRoots: this.inputRoots,
20749
+ relativePaths,
20750
+ logger: this.logger,
20751
+ artifactName: "The hooks file"
20752
+ }) ?? this.inputRoots[0];
20039
20753
  try {
20040
20754
  return [await RulesyncHooks.fromFile({
20041
- outputRoot: this.inputRoot,
20755
+ outputRoot: (0, node_path.dirname)(sourceTree),
20756
+ relativeDirPath: (0, node_path.basename)(sourceTree),
20042
20757
  validate: true
20043
20758
  })];
20044
20759
  } catch (error) {
@@ -21580,10 +22295,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21580
22295
  getFactory;
21581
22296
  featureOptions;
21582
22297
  global;
21583
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
22298
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$4, global = false, dryRun = false, logger, featureOptions }) {
21584
22299
  super({
21585
22300
  outputRoot,
21586
- inputRoot,
22301
+ inputRoots,
21587
22302
  dryRun,
21588
22303
  logger
21589
22304
  });
@@ -21600,11 +22315,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21600
22315
  }
21601
22316
  /**
21602
22317
  * Implementation of abstract method from FeatureProcessor
21603
- * Load and parse rulesync ignore files from .rulesync/ignore/ directory
22318
+ *
22319
+ * Load and parse the rulesync ignore file. `inputRoots[i]` is a source
22320
+ * tree itself (e.g. `/repo/.rulesync.local`); the recommended `.aiignore`
22321
+ * lives directly inside it. The legacy `.rulesyncignore` is shared at the
22322
+ * project root, so it is intentionally not considered when choosing which
22323
+ * source tree wins; `RulesyncIgnore.fromFile` still uses it as a fallback
22324
+ * for the chosen tree.
22325
+ *
22326
+ * When multiple input roots are configured, the last root that provides
22327
+ * an ignore file wins entirely (whole-file replacement — no line-level
22328
+ * merge in this slice; see the "Deliberately out of scope" section of
22329
+ * the inputRoots plan for context). If no root has the file, fall back
22330
+ * to the primary root's path so the underlying `RulesyncIgnore.fromFile`
22331
+ * surfaces the same missing-file error it would in the single-root case.
21604
22332
  */
21605
22333
  async loadRulesyncFiles() {
22334
+ const paths = RulesyncIgnore.getSettablePaths();
22335
+ const relativePaths = getRulesyncSourceCandidates({ paths }).filter((candidate) => candidate.relativeDirPath === paths.recommended.relativeDirPath).map((candidate) => candidate.relativeFilePath);
22336
+ const sourceTree = await pickLastRootWithFile({
22337
+ inputRoots: this.inputRoots,
22338
+ relativePaths,
22339
+ logger: this.logger,
22340
+ artifactName: "The ignore file (.aiignore)"
22341
+ }) ?? this.inputRoots[0];
21606
22342
  try {
21607
- return [await RulesyncIgnore.fromFile({ outputRoot: this.inputRoot })];
22343
+ return [await RulesyncIgnore.fromFile({
22344
+ outputRoot: (0, node_path.dirname)(sourceTree),
22345
+ relativeDirPath: (0, node_path.basename)(sourceTree)
22346
+ })];
21608
22347
  } catch (error) {
21609
22348
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21610
22349
  return [];
@@ -26639,11 +27378,86 @@ function disabledNamesOf(config) {
26639
27378
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26640
27379
  }
26641
27380
  /**
27381
+ * The value `mcp.mcpConfigPath` needs so Rovo Dev reads the project-scope
27382
+ * `.rovodev/mcp.json`. A config-file value, not a filesystem path, so it is
27383
+ * always POSIX-separated.
27384
+ */
27385
+ const ROVODEV_PROJECT_MCP_CONFIG_POINTER = node_path.posix.join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME);
27386
+ /**
27387
+ * The keys that make an entry in `mcp.json` something Rovo Dev can start:
27388
+ * a local process, or a remote endpoint under either spelling the canonical
27389
+ * config accepts.
27390
+ */
27391
+ const MCP_SERVER_ENDPOINT_KEYS = [
27392
+ "command",
27393
+ "url",
27394
+ "httpUrl"
27395
+ ];
27396
+ function normalizeMcpConfigPathValue(value) {
27397
+ return toPosixPath(value).replace(/^\.\//, "");
27398
+ }
27399
+ /**
27400
+ * Point `mcp.mcpConfigPath` at the project-scope `mcp.json` rulesync writes,
27401
+ * and report whether the block gained a value it did not already carry.
27402
+ *
27403
+ * Rovo Dev's `mcpConfigPath` defaults to a file under the user's home
27404
+ * directory, so a repo-committed `.rovodev/mcp.json` is inert until the active
27405
+ * config points at it — the Bitbucket Agentic Pipelines guide documents
27406
+ * registering the server and setting the pointer as two required steps. Global
27407
+ * scope is left alone: there the default already resolves to the file rulesync
27408
+ * writes.
27409
+ *
27410
+ * The pointer names one config rather than merging with the default, so it is
27411
+ * written only when this project actually has a Rovo Dev server to run — a
27412
+ * server that targets `rovodev` and is not disabled. Otherwise `mcp.json` is
27413
+ * generated empty, and pointing at it would take away the user's global
27414
+ * servers for this repository in exchange for nothing.
27415
+ *
27416
+ * That condition can also stop holding after the fact, once the last server is
27417
+ * removed from the canonical config or switched off. Rulesync does not take
27418
+ * the pointer back out — it cannot tell its own past value from a user who
27419
+ * typed the same string — but it says so, since the file is now a live setting
27420
+ * that resolves to nothing.
27421
+ *
27422
+ * A pointer the user aimed somewhere else is theirs, not ours: overwriting it
27423
+ * would silently redirect Rovo Dev away from a file they chose. It is named in
27424
+ * a warning instead, because the generated `mcp.json` is unread while it
27425
+ * stands.
27426
+ *
27427
+ * Whatever the outcome, it is logged: writing the pointer turns servers that
27428
+ * were generated-but-never-read into servers Rovo Dev actually spawns, and
27429
+ * points it away from the global MCP config, so it is not something to do
27430
+ * quietly.
27431
+ *
27432
+ * @see https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/
27433
+ * @see https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/
27434
+ */
27435
+ function applyProjectMcpConfigPointer({ existingMcp, global, hasLiveServers, logger }) {
27436
+ if (global) return false;
27437
+ const existing = existingMcp.mcpConfigPath;
27438
+ const pointsAtGeneratedFile = typeof existing === "string" && normalizeMcpConfigPathValue(existing) === ROVODEV_PROJECT_MCP_CONFIG_POINTER;
27439
+ if (!hasLiveServers) {
27440
+ if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} points at ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which now has no enabled server. Rovo Dev reads that file instead of the global MCP config, so this project has no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to the global config.`);
27441
+ return false;
27442
+ }
27443
+ if (existing === void 0) {
27444
+ existingMcp.mcpConfigPath = ROVODEV_PROJECT_MCP_CONFIG_POINTER;
27445
+ logger?.info(`Rovo Dev MCP: setting mcp.mcpConfigPath to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}" in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev will now launch the servers in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} for this project instead of the ones in the default global MCP config.`);
27446
+ return true;
27447
+ }
27448
+ if (pointsAtGeneratedFile) return false;
27449
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Rovo Dev reads MCP servers from that path, so the generated ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} is unused until it is set to "${ROVODEV_PROJECT_MCP_CONFIG_POINTER}".`);
27450
+ return false;
27451
+ }
27452
+ /**
26642
27453
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26643
27454
  * `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
26644
- * Rovo Dev actually consults to switch a server off — recomputed from the
26645
- * existing block so user keys (`mcpConfigPath`, `allowedMcpServers`, ...) and
26646
- * disabled names for servers rulesync does not manage survive.
27455
+ * Rovo Dev actually consults to switch a server off — plus `mcpConfigPath`,
27456
+ * which rulesync authors in project scope when the key is absent and this
27457
+ * project has a server to run (see `applyProjectMcpConfigPointer`). The block
27458
+ * is recomputed from the existing one, so user keys (`allowedMcpServers`,
27459
+ * ...), a `mcpConfigPath` the user aimed elsewhere, and disabled names for
27460
+ * servers rulesync does not manage all survive.
26647
27461
  */
26648
27462
  var RovodevMcpConfigYaml = class extends ToolFile {
26649
27463
  isDeletable() {
@@ -26776,7 +27590,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26776
27590
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26777
27591
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26778
27592
  else delete existingMcp.disabledMcpServers;
26779
- if (mergedDisabled.length === 0 && existingContent.trim() === "") return [];
27593
+ const wrotePointer = applyProjectMcpConfigPointer({
27594
+ existingMcp,
27595
+ global,
27596
+ hasLiveServers: managedNames.filter((name) => {
27597
+ if (disabledNames.includes(name)) return false;
27598
+ const server = servers[name];
27599
+ return isRecord$1(server) && MCP_SERVER_ENDPOINT_KEYS.some((endpointKey) => server[endpointKey] !== void 0);
27600
+ }).length > 0,
27601
+ logger
27602
+ });
27603
+ if (mergedDisabled.length === 0 && !wrotePointer && existingContent.trim() === "") return [];
26780
27604
  const fileContent = applySharedConfigPatch({
26781
27605
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26782
27606
  feature: "mcp",
@@ -27869,10 +28693,10 @@ var McpProcessor = class extends FeatureProcessor {
27869
28693
  toolTarget;
27870
28694
  global;
27871
28695
  getFactory;
27872
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
28696
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$3, dryRun = false, logger }) {
27873
28697
  super({
27874
28698
  outputRoot,
27875
- inputRoot,
28699
+ inputRoots,
27876
28700
  dryRun,
27877
28701
  logger
27878
28702
  });
@@ -27888,7 +28712,10 @@ var McpProcessor = class extends FeatureProcessor {
27888
28712
  */
27889
28713
  async loadRulesyncFiles() {
27890
28714
  try {
27891
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
28715
+ return [await RulesyncMcp.fromRoots({
28716
+ inputRoots: this.inputRoots,
28717
+ logger: this.logger
28718
+ })];
27892
28719
  } catch (error) {
27893
28720
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27894
28721
  return [];
@@ -29430,27 +30257,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
29430
30257
  return { permission };
29431
30258
  }
29432
30259
  //#endregion
29433
- //#region src/utils/control-characters.ts
29434
- /**
29435
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
29436
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
29437
- * line and paragraph separators, and the plain LRM/RLM marks. A name or value
29438
- * copied out of an untrusted config file, a fetched repository, or a tool's own
29439
- * settings file must never reach the terminal with these intact: they let the
29440
- * text forge log lines, reorder what is printed around them, or inject escape
29441
- * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
29442
- * neutral characters beside them, so they go too — a diagnostic line is not the
29443
- * place to preserve the typography of a right-to-left name.
29444
- */
29445
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
29446
- /**
29447
- * Removes every control character from `text` so it is safe to splice into a
29448
- * log line or other terminal output.
29449
- */
29450
- function stripControlCharacters(text) {
29451
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
29452
- }
29453
- //#endregion
29454
30260
  //#region src/features/permissions/claudecode-permissions.ts
29455
30261
  /**
29456
30262
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -29536,8 +30342,8 @@ function deepMergeRecords(base, patch) {
29536
30342
  *
29537
30343
  * Deliberately NOT listed:
29538
30344
  * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
29539
- * `stripCommandExecutingSandboxPaths` refuses them in both scopes rather than
29540
- * emitting them under `--global`.
30345
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
30346
+ * rather than emitting them under `--global`.
29541
30347
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29542
30348
  * unit is the individual entry's mode, not the settings key, and the same
29543
30349
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29556,6 +30362,30 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29556
30362
  ["allowAppleEvents"]
29557
30363
  ];
29558
30364
  /**
30365
+ * `sandbox.*` paths documented with a `Managed` scope: Claude Code reads them
30366
+ * only from the settings file an organization deploys. Neither file rulesync
30367
+ * writes is that file, so they are dropped in **both** scopes — the `sandbox`
30368
+ * counterpart of {@link CLAUDECODE_UNHONORED_KEY_SOURCES}, which does the same
30369
+ * for top-level `Managed` keys.
30370
+ *
30371
+ * Both only ever *narrow* the policy — they stop a lower-scoped file from
30372
+ * re-opening what managed settings blocked — so neither is trust-widening. They
30373
+ * are dropped rather than written for the opposite reason: written into a
30374
+ * project or user file they do nothing at all, and a `sandbox` block that reads
30375
+ * as though it locked the policy to managed values while Claude Code ignores it
30376
+ * is the more dangerous of the two failure modes.
30377
+ *
30378
+ * Import keeps them, unlike the command-executing paths: the value in an
30379
+ * existing `settings.json` was hand-written to be honored somewhere, and
30380
+ * round-tripping it preserves the author's intent for the day it moves into a
30381
+ * managed file. The cost is a warning on every generate until it is removed,
30382
+ * which the refusal message points at.
30383
+ *
30384
+ * @see https://code.claude.com/docs/en/settings-reference#sandbox-filesystem-allowmanagedreadpathsonly
30385
+ * — "Scope: `Managed`"; the `network` entry says the same.
30386
+ */
30387
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadPathsOnly"], ["network", "allowManagedDomainsOnly"]];
30388
+ /**
29559
30389
  * Walks `segments` from `root`, returning the record they name or `undefined` if
29560
30390
  * any step is missing or not a record. Shared by everything below that addresses
29561
30391
  * a `sandbox` path, so a nested path added to one of the tables is actually
@@ -29602,6 +30432,18 @@ function deleteSandboxPath({ target, path }) {
29602
30432
  return true;
29603
30433
  }
29604
30434
  /**
30435
+ * The one warning that names every trust-affecting setting this generate wrote
30436
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
30437
+ * matter, but the "review this as you would a hook" framing only needs saying
30438
+ * once, and repeating it per key buries the reasons in boilerplate.
30439
+ */
30440
+ function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
30441
+ if (entries.length === 0) return;
30442
+ const one = entries.length === 1;
30443
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
30444
+ 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}.`);
30445
+ }
30446
+ /**
29605
30447
  * The `permissions.defaultMode` values that start a session with fewer prompts
29606
30448
  * than the default. `plan` and `default` are absent because they do not widen
29607
30449
  * anything.
@@ -29614,14 +30456,22 @@ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
29614
30456
  /**
29615
30457
  * The `permissions` fields that widen rather than restrict: a `defaultMode` that
29616
30458
  * removes prompts, and `additionalDirectories`, which moves the
29617
- * working-directory boundary. Warned for the same reason `disableAllHooks` is: a
29618
- * shareable permissions file should not loosen the permission system quietly.
30459
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
30460
+ * a shareable permissions file should not loosen the permission system quietly.
29619
30461
  */
29620
- function warnOnWideningPermissionFields({ fields, relativeFilePath, logger }) {
30462
+ function collectWideningPermissionFields({ fields }) {
30463
+ const entries = [];
29621
30464
  const defaultMode = fields.defaultMode;
29622
- if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) logger?.warn(`Claude Code permissions: writing 'permissions.defaultMode: "${defaultMode}"' to ${relativeFilePath}; ${CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]}. Review it as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
30465
+ if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) entries.push({
30466
+ label: `permissions.defaultMode: "${defaultMode}"`,
30467
+ reason: CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]
30468
+ });
29623
30469
  const additionalDirectories = fields.additionalDirectories;
29624
- if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) logger?.warn(`Claude Code permissions: writing 'permissions.additionalDirectories' to ${relativeFilePath}; it moves the boundary of what Claude Code may read and edit outside the project. Review the paths, especially if this permissions file came from 'rulesync fetch'.`);
30470
+ if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) entries.push({
30471
+ label: "permissions.additionalDirectories",
30472
+ reason: "moves the boundary of what Claude Code may read and edit outside the project"
30473
+ });
30474
+ return entries;
29625
30475
  }
29626
30476
  /**
29627
30477
  * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
@@ -29638,6 +30488,18 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
29638
30488
  ["socatPath"]
29639
30489
  ];
29640
30490
  /**
30491
+ * The predicates the "which value actually widens?" tables are built from.
30492
+ * Each names the value that does *not* widen and reports everything else, never
30493
+ * the reverse: the override is authored JSONC, so a key can carry any value at
30494
+ * all, and one Claude Code coerces is still honored. Reporting an off-type value
30495
+ * keeps the warning fail-safe — silence has to mean "this cannot loosen
30496
+ * anything", not "this is not the type the table expected".
30497
+ */
30498
+ const isNotFalse = (value) => value !== false;
30499
+ const isNotTrue = (value) => value !== true;
30500
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
30501
+ const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
30502
+ /**
29641
30503
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
29642
30504
  * they let commands out of it, weaken the isolation it provides, or redirect
29643
30505
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -29657,77 +30519,77 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29657
30519
  {
29658
30520
  path: ["allowAppleEvents"],
29659
30521
  reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
29660
- widens: (value) => value === true
30522
+ widens: isNotFalse
29661
30523
  },
29662
30524
  {
29663
30525
  path: ["allowUnsandboxedCommands"],
29664
30526
  reason: "controls whether Claude may retry a blocked command outside the sandbox",
29665
- widens: (value) => value !== false
30527
+ widens: isNotFalse
29666
30528
  },
29667
30529
  {
29668
30530
  path: ["autoAllowBashIfSandboxed"],
29669
30531
  reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
29670
- widens: (value) => value !== false
30532
+ widens: isNotFalse
29671
30533
  },
29672
30534
  {
29673
30535
  path: ["enableWeakerNestedSandbox"],
29674
30536
  reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
29675
- widens: (value) => value === true
30537
+ widens: isNotFalse
29676
30538
  },
29677
30539
  {
29678
30540
  path: ["enableWeakerNetworkIsolation"],
29679
30541
  reason: "weakens the sandbox's network isolation on macOS",
29680
- widens: (value) => value === true
30542
+ widens: isNotFalse
29681
30543
  },
29682
30544
  {
29683
30545
  path: ["enabled"],
29684
30546
  reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
29685
- widens: (value) => value === true
30547
+ widens: isNotFalse
29686
30548
  },
29687
30549
  {
29688
30550
  path: ["excludedCommands"],
29689
30551
  reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
29690
- widens: (value) => !Array.isArray(value) || value.length > 0
30552
+ widens: isNonEmptyList
29691
30553
  },
29692
30554
  {
29693
30555
  path: ["filesystem", "allowRead"],
29694
30556
  reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
29695
- widens: (value) => !Array.isArray(value) || value.length > 0
30557
+ widens: isNonEmptyList
29696
30558
  },
29697
30559
  {
29698
30560
  path: ["filesystem", "allowWrite"],
29699
30561
  reason: "adds paths sandboxed commands may write to, outside the working directory",
29700
- widens: (value) => !Array.isArray(value) || value.length > 0
30562
+ widens: isNonEmptyList
29701
30563
  },
29702
30564
  {
29703
30565
  path: ["ignoreViolations"],
29704
30566
  reason: "hides the sandbox violations it names, so a blocked access stops being reported",
29705
- widens: (value) => isPlainRecord(value) ? Object.keys(value).length > 0 : value !== false
30567
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
29706
30568
  },
29707
30569
  {
29708
30570
  path: ["network", "allowAllUnixSockets"],
29709
30571
  reason: "lets sandboxed commands connect to every Unix socket",
29710
- widens: (value) => value === true
30572
+ widens: isNotFalse
29711
30573
  },
29712
30574
  {
29713
30575
  path: ["network", "allowedDomains"],
29714
30576
  reason: "pre-allows domains sandboxed commands may reach without a prompt",
29715
- widens: (value) => !Array.isArray(value) || value.length > 0
30577
+ widens: isNonEmptyList
29716
30578
  },
29717
30579
  {
29718
30580
  path: ["network", "allowLocalBinding"],
29719
30581
  reason: "lets sandboxed commands bind local ports",
29720
- widens: (value) => value === true
30582
+ widens: isNotFalse
29721
30583
  },
29722
30584
  {
29723
30585
  path: ["network", "allowMachLookup"],
29724
30586
  reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
29725
- widens: (value) => !Array.isArray(value) || value.length > 0
30587
+ widens: isNonEmptyList
29726
30588
  },
29727
30589
  {
29728
30590
  path: ["network", "allowUnixSockets"],
29729
30591
  reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
29730
- widens: (value) => !Array.isArray(value) || value.length > 0
30592
+ widens: isNonEmptyList
29731
30593
  },
29732
30594
  {
29733
30595
  path: ["network", "httpProxyPort"],
@@ -29741,11 +30603,12 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29741
30603
  }
29742
30604
  ];
29743
30605
  /**
29744
- * Warns once per authored `sandbox` path that loosens the sandbox. Nothing is
29745
- * removed — the value is written, just not silently. Called on the filtered
29746
- * `sandbox` so it never claims to be writing a path the scope filters dropped.
30606
+ * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
30607
+ * the values are written, just not silently. Called on the filtered `sandbox`
30608
+ * so it never claims to be writing a path the scope filters dropped.
29747
30609
  */
29748
- function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger }) {
30610
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
30611
+ const entries = [];
29749
30612
  for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29750
30613
  const leaf = path.at(-1);
29751
30614
  if (leaf === void 0) continue;
@@ -29756,38 +30619,49 @@ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger })
29756
30619
  if (parent === void 0) continue;
29757
30620
  const value = parent[leaf];
29758
30621
  if (value === void 0 || !widens(value)) continue;
29759
- logger?.warn(`Claude Code permissions: writing 'sandbox.${path.join(".")}' to ${relativeFilePath}; it ${reason}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
30622
+ entries.push({
30623
+ label: `sandbox.${path.join(".")}`,
30624
+ reason
30625
+ });
29760
30626
  }
30627
+ return entries;
29761
30628
  }
30629
+ /** Paths that name an executable Claude Code runs. Refused in both scopes. */
30630
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
30631
+ paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
30632
+ 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.`
30633
+ };
29762
30634
  /**
29763
- * Copy of the authored `sandbox` override with the paths that name an
29764
- * executable removed, warning once per dropped path.
30635
+ * Paths Claude Code honors only from managed settings. Refused in both scopes,
30636
+ * like the command-executing paths, because managed settings are not a file
30637
+ * rulesync writes in either of them.
29765
30638
  */
29766
- function stripCommandExecutingSandboxPaths({ sandbox, relativeFilePath, logger }) {
29767
- const filtered = structuredClone(sandbox);
29768
- for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) {
29769
- if (!deleteSandboxPath({
29770
- target: filtered,
29771
- path
29772
- })) continue;
29773
- logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' names an executable Claude Code runs, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; set this path in ${relativeFilePath} by hand.`);
29774
- }
29775
- return filtered;
29776
- }
30639
+ const CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL = {
30640
+ paths: CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS,
30641
+ 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.`
30642
+ };
30643
+ /** Paths Claude Code honors only above project scope. Refused at project scope. */
30644
+ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL = {
30645
+ paths: CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS,
30646
+ 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.`
30647
+ };
29777
30648
  /**
29778
- * Copy of the authored `sandbox` override with the user/managed-only paths
29779
- * removed, warning once per dropped path. Only the override copy is filtered —
29780
- * a value already hand-written in the target file is left untouched, matching
29781
- * the `qwencode` `security.allowPrivateNetworkHooks` precedent.
30649
+ * Copy of the authored `sandbox` override with every path of every passed
30650
+ * refusal removed, warning once per dropped path. Only the override copy is
30651
+ * filtered — a value already hand-written in the target file is left untouched,
30652
+ * matching the `qwencode` `security.allowPrivateNetworkHooks` precedent.
29782
30653
  */
29783
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
30654
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
29784
30655
  const filtered = structuredClone(sandbox);
29785
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
30656
+ for (const { paths, warn } of refusals) for (const path of paths) {
29786
30657
  if (!deleteSandboxPath({
29787
30658
  target: filtered,
29788
30659
  path
29789
30660
  })) continue;
29790
- logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' is only honored in user/managed/--settings settings, so it is not written to the project-scoped ${relativeFilePath}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
30661
+ logger?.warn(warn({
30662
+ label: `sandbox.${path.join(".")}`,
30663
+ relativeFilePath
30664
+ }));
29791
30665
  }
29792
30666
  return filtered;
29793
30667
  }
@@ -29811,7 +30685,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29811
30685
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29812
30686
  * against cannot slip through a differently-spelled value.
29813
30687
  *
29814
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
30688
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29815
30689
  * value already in the target file is left untouched, which is why the warning
29816
30690
  * points at it.
29817
30691
  *
@@ -29978,6 +30852,9 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29978
30852
  allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
29979
30853
  allowedMcpServers: "allowlists the MCP servers that may be used, and entries from every settings file merge into one list, so an entry here widens an allowlist deployed elsewhere",
29980
30854
  autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
30855
+ claudeMdExcludes: "skips the CLAUDE.md files its patterns match, so the instructions a repository relies on can be dropped from every session",
30856
+ 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",
30857
+ crossSessionInbound: "decides what a session does with messages arriving from your other Claude Code sessions, and `accept` delivers them straight to Claude",
29981
30858
  disableAllHooks: "controls whether hooks run at all",
29982
30859
  disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
29983
30860
  enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
@@ -29986,17 +30863,58 @@ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29986
30863
  env: "sets environment variables for every process Claude Code spawns, so a value such as `NODE_OPTIONS` or `PATH` runs code and `ANTHROPIC_BASE_URL` redirects every prompt",
29987
30864
  extraKnownMarketplaces: "registers plugin marketplace sources",
29988
30865
  httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
30866
+ modelOverrides: "maps the model IDs it names to provider-specific model IDs, so an entry decides which inference profile or deployment every call for that model is routed to",
29989
30867
  outputStyle: "replaces the system prompt every session runs with",
30868
+ 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",
30869
+ remoteControlAtStartup: "connects Remote Control automatically at session start, and the transcript of a connected session is stored on Anthropic servers to sync it across devices",
29990
30870
  skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
29991
- skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
30871
+ skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts",
30872
+ skipWebFetchPreflight: "turns off the WebFetch domain safety check, so WebFetch retrieves any URL without consulting Anthropic's blocklist"
29992
30873
  };
29993
30874
  /**
29994
30875
  * The keys from the table above that only widen at one particular value.
29995
30876
  * `disableSkillShellExecution: true` turns inline shell execution off, which
29996
- * restricts; the `false` that turns it back on is what a fetched override could
29997
- * use to undo a user setting, so only that value is warned about.
29998
- */
29999
- const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = { disableSkillShellExecution: (value) => value === false };
30877
+ * restricts; anything else re-opens it, and that is what a fetched override
30878
+ * could use to undo a user setting. The rest default to off, so only a value
30879
+ * other than the one that leaves them off is worth a line — and for the
30880
+ * list-valued and map-valued keys, only a non-empty one, since an empty list or
30881
+ * map excludes, announces and overrides nothing.
30882
+ */
30883
+ const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = {
30884
+ claudeMdExcludes: isNonEmptyList,
30885
+ companyAnnouncements: isNonEmptyList,
30886
+ crossSessionInbound: (value) => value !== "hold" && value !== "refuse",
30887
+ disableSkillShellExecution: isNotTrue,
30888
+ modelOverrides: isNonEmptyMap,
30889
+ remoteControlAtStartup: isNotFalse,
30890
+ skipWebFetchPreflight: isNotFalse
30891
+ };
30892
+ /**
30893
+ * Top-level keys a project-scoped `.claude/settings.json` honors at one value
30894
+ * but ignores at another — the value-level counterpart of
30895
+ * {@link CLAUDECODE_USER_SCOPE_ONLY_KEYS}, which is scoped per key. The ignored
30896
+ * value is dropped at project scope for the same reason a wholly unhonored key
30897
+ * is: committing it would read as a policy that never applies.
30898
+ *
30899
+ * `remoteControlAtStartup` is the only entry whose honored value can be decided
30900
+ * from the value alone. Claude Code honors a `false` from project or local
30901
+ * settings — a repository may turn auto-connect off for its own checkout — but
30902
+ * ignores a `true`, so that a checked-in file cannot turn Remote Control on for
30903
+ * everyone who opens the repository.
30904
+ *
30905
+ * `crossSessionInbound` is on the same documented list but deliberately absent
30906
+ * here: it is a ladder (`accept` < `hold` < `refuse`) whose project value is
30907
+ * honored only when it is stricter than the one above it, which no per-value
30908
+ * predicate can decide without reading the user's own settings. It is warned
30909
+ * about through {@link CLAUDECODE_TRUST_AFFECTING_KEYS} instead, since under
30910
+ * `--global` its loosening value is honored outright.
30911
+ *
30912
+ * @see https://code.claude.com/docs/en/settings#security-keys-where-the-stricter-value-applies
30913
+ */
30914
+ const CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES = { remoteControlAtStartup: {
30915
+ ignored: isNotFalse,
30916
+ 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"
30917
+ } };
30000
30918
  /**
30001
30919
  * A key name is authored data that ends up in a log line, so strip the control
30002
30920
  * characters that would let it forge a line or hide the warnings beside it, and
@@ -30024,12 +30942,13 @@ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
30024
30942
  /**
30025
30943
  * Copy of the authored top-level passthrough with the keys the target file
30026
30944
  * cannot honor removed, warning once per dropped key. Like
30027
- * `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a value
30945
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30028
30946
  * already hand-written in the target file is left untouched, which is why the
30029
30947
  * warning points at it.
30030
30948
  */
30031
30949
  function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30032
30950
  const filtered = {};
30951
+ const trustAffecting = [];
30033
30952
  for (const [key, value] of Object.entries(overrides)) {
30034
30953
  const shown = displayKey(key);
30035
30954
  const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
@@ -30045,11 +30964,22 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
30045
30964
  logger?.warn(`Claude Code permissions: '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — Claude Code reads it from user, local or managed settings. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
30046
30965
  continue;
30047
30966
  }
30048
- const widensAtValue = CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey];
30049
- if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) logger?.warn(`Claude Code permissions: writing '${shown}' to ${relativeFilePath}; it ${CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
30967
+ const projectIgnored = Object.hasOwn(CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES, canonicalKey) ? CLAUDECODE_PROJECT_SCOPE_IGNORED_VALUES[canonicalKey] : void 0;
30968
+ if (!global && projectIgnored !== void 0 && projectIgnored.ignored(value)) {
30969
+ 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.`);
30970
+ continue;
30971
+ }
30972
+ const widensAtValue = Object.hasOwn(CLAUDECODE_TRUST_KEY_WIDENING_VALUES, canonicalKey) ? CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey] : void 0;
30973
+ if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) trustAffecting.push({
30974
+ label: shown,
30975
+ reason: CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]
30976
+ });
30050
30977
  filtered[key] = value;
30051
30978
  }
30052
- return filtered;
30979
+ return {
30980
+ filtered,
30981
+ trustAffecting
30982
+ };
30053
30983
  }
30054
30984
  const CLAUDE_PATH_RULE_ALIASES = {
30055
30985
  Write: "Edit",
@@ -30119,15 +31049,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30119
31049
  config,
30120
31050
  logger
30121
31051
  });
31052
+ const trustAffecting = [];
30122
31053
  const overridePermissions = config.claudecode?.permissions;
30123
31054
  if (overridePermissions && typeof overridePermissions === "object") {
30124
31055
  const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
30125
31056
  const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
30126
- warnOnWideningPermissionFields({
30127
- fields: nonListFields,
30128
- relativeFilePath: paths.relativeFilePath,
30129
- logger
30130
- });
31057
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
30131
31058
  settings.permissions = {
30132
31059
  ...settings.permissions,
30133
31060
  ...nonListFields
@@ -30135,25 +31062,22 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30135
31062
  }
30136
31063
  const overrideSandbox = config.claudecode?.sandbox;
30137
31064
  if (isPlainRecord(overrideSandbox)) {
30138
- const executableFreeSandbox = stripCommandExecutingSandboxPaths({
31065
+ const honorableSandbox = stripSandboxPaths({
30139
31066
  sandbox: overrideSandbox,
31067
+ refusals: [
31068
+ CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL,
31069
+ CLAUDECODE_MANAGED_ONLY_SANDBOX_REFUSAL,
31070
+ ...global ? [] : [CLAUDECODE_GLOBAL_ONLY_SANDBOX_REFUSAL]
31071
+ ],
30140
31072
  relativeFilePath: paths.relativeFilePath,
30141
31073
  logger
30142
31074
  });
30143
- const scopedSandbox = global ? executableFreeSandbox : stripProjectIgnoredMaskEntries({
30144
- sandbox: stripGlobalOnlySandboxPaths({
30145
- sandbox: executableFreeSandbox,
30146
- relativeFilePath: paths.relativeFilePath,
30147
- logger
30148
- }),
30149
- relativeFilePath: paths.relativeFilePath,
30150
- logger
30151
- });
30152
- warnOnTrustAffectingSandboxPaths({
30153
- sandbox: scopedSandbox,
31075
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
31076
+ sandbox: honorableSandbox,
30154
31077
  relativeFilePath: paths.relativeFilePath,
30155
31078
  logger
30156
31079
  });
31080
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
30157
31081
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
30158
31082
  }
30159
31083
  const overrideTopLevel = {};
@@ -30163,13 +31087,19 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
30163
31087
  if (value === void 0) continue;
30164
31088
  overrideTopLevel[key] = value;
30165
31089
  }
30166
- const scopedTopLevel = stripUnhonoredTopLevelKeys({
31090
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
30167
31091
  overrides: overrideTopLevel,
30168
31092
  global,
30169
31093
  relativeFilePath: paths.relativeFilePath,
30170
31094
  logger
30171
31095
  });
31096
+ trustAffecting.push(...trustAffectingTopLevel);
30172
31097
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
31098
+ warnOnTrustAffectingEntries({
31099
+ entries: trustAffecting,
31100
+ relativeFilePath: paths.relativeFilePath,
31101
+ logger
31102
+ });
30173
31103
  const managedToolNames = managedClaudeToolNames(config);
30174
31104
  const merged = applyPermissions({
30175
31105
  settings,
@@ -35147,11 +36077,8 @@ const TOOL_KEY_TO_CATEGORY = {
35147
36077
  updateConfluencePage: "edit"
35148
36078
  };
35149
36079
  const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
35150
- const OWNED_TOOL_PERMISSION_KEYS = [
35151
- "bash",
35152
- "allowedExternalPaths",
35153
- "default"
35154
- ];
36080
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
36081
+ const MANAGED_BASH_KEYS = ["default", "commands"];
35155
36082
  /**
35156
36083
  * Permissions adapter for Rovo Dev CLI.
35157
36084
  *
@@ -35287,6 +36214,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35287
36214
  }
35288
36215
  };
35289
36216
  /**
36217
+ * Report a `toolPermissions.bash.runInSandbox: false` that this generate is
36218
+ * about to carry through.
36219
+ *
36220
+ * The unmanaged `bash` siblings survive untouched, which for this one means a
36221
+ * generate is not the reset a user might read it as: every command the agent
36222
+ * runs stays outside the sandbox. Rulesync never authors the key and will not
36223
+ * start owning it, but a setting that persists on a committed file and loosens
36224
+ * containment should not do so without saying anything — least of all on the
36225
+ * path where the user just tightened `.rulesync/permissions.*`.
36226
+ */
36227
+ function warnAboutPreservedSandboxOptOut({ existingToolPermissions, filePath, logger }) {
36228
+ const existingBash = existingToolPermissions.bash;
36229
+ if (!isRecord$1(existingBash) || existingBash.runInSandbox !== false) return;
36230
+ 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.`);
36231
+ }
36232
+ /**
35290
36233
  * Resolve the `toolPermissions` block to write, merging the generated levels
35291
36234
  * over the existing file. Every other top-level key of `config.yml` is the
35292
36235
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -35294,6 +36237,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
35294
36237
  */
35295
36238
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
35296
36239
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
36240
+ warnAboutPreservedSandboxOptOut({
36241
+ existingToolPermissions,
36242
+ filePath,
36243
+ logger
36244
+ });
35297
36245
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
35298
36246
  if (!isRecord$1(existing)) return;
35299
36247
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -35302,9 +36250,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35302
36250
  }
35303
36251
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
35304
36252
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
36253
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
36254
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
35305
36255
  warnAboutDroppedOwnedKeys({
35306
36256
  existingToolPermissions,
35307
36257
  existingTools,
36258
+ existingBash,
35308
36259
  generated,
35309
36260
  filePath,
35310
36261
  logger
@@ -35314,15 +36265,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35314
36265
  delete existingToolPermissions[toolKey];
35315
36266
  delete existingTools[toolKey];
35316
36267
  }
36268
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
35317
36269
  const tools = {
35318
36270
  ...existingTools,
35319
36271
  ...generated.tools
35320
36272
  };
36273
+ const bash = {
36274
+ ...existingBash,
36275
+ ...generated.bash
36276
+ };
35321
36277
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
36278
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
35322
36279
  return {
35323
36280
  ...existingToolPermissions,
35324
36281
  ...generated,
35325
- ...Object.keys(tools).length > 0 ? { tools } : {}
36282
+ ...Object.keys(tools).length > 0 ? { tools } : {},
36283
+ ...Object.keys(bash).length > 0 ? { bash } : {}
35326
36284
  };
35327
36285
  }
35328
36286
  /**
@@ -35331,9 +36289,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
35331
36289
  * `/directories` and by an "always allow" answer to a prompt — so their removal
35332
36290
  * must not be silent.
35333
36291
  */
35334
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
36292
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
35335
36293
  const newTools = generated.tools ?? {};
35336
- const droppedKeys = [...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0), ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`)];
36294
+ const newBash = generated.bash ?? {};
36295
+ const droppedKeys = [
36296
+ ...OWNED_TOOL_PERMISSION_KEYS.filter((ownedKey) => existingToolPermissions[ownedKey] !== void 0 && generated[ownedKey] === void 0),
36297
+ ...MANAGED_TOOL_KEYS.filter((toolKey) => existingTools[toolKey] !== void 0 && newTools[toolKey] === void 0).map((toolKey) => `tools.${toolKey}`),
36298
+ ...MANAGED_BASH_KEYS.filter((bashKey) => existingBash[bashKey] !== void 0 && newBash[bashKey] === void 0).map((bashKey) => `bash.${bashKey}`)
36299
+ ];
35337
36300
  if (droppedKeys.length > 0) logger?.warn(`Rovo Dev permissions: removing ${droppedKeys.map((key) => `"${key}"`).join(", ")} from ${filePath} because the rulesync source no longer produces them.`);
35338
36301
  }
35339
36302
  /**
@@ -37441,10 +38404,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
37441
38404
  var PermissionsProcessor = class extends FeatureProcessor {
37442
38405
  toolTarget;
37443
38406
  global;
37444
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, dryRun = false, logger }) {
38407
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
37445
38408
  super({
37446
38409
  outputRoot,
37447
- inputRoot,
38410
+ inputRoots,
37448
38411
  dryRun,
37449
38412
  logger
37450
38413
  });
@@ -37454,9 +38417,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
37454
38417
  this.global = global;
37455
38418
  }
37456
38419
  async loadRulesyncFiles() {
38420
+ const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncPermissions.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
38421
+ const sourceTree = await pickLastRootWithFile({
38422
+ inputRoots: this.inputRoots,
38423
+ relativePaths,
38424
+ logger: this.logger,
38425
+ artifactName: "The permissions file"
38426
+ }) ?? this.inputRoots[0];
37457
38427
  try {
37458
38428
  return [await RulesyncPermissions.fromFile({
37459
- outputRoot: this.inputRoot,
38429
+ outputRoot: (0, node_path.dirname)(sourceTree),
38430
+ relativeDirPath: (0, node_path.basename)(sourceTree),
37460
38431
  validate: true
37461
38432
  })];
37462
38433
  } catch (error) {
@@ -37880,13 +38851,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37880
38851
  //#region src/types/dir-feature-processor.ts
37881
38852
  var DirFeatureProcessor = class {
37882
38853
  outputRoot;
37883
- inputRoot;
38854
+ /**
38855
+ * Ordered, non-empty list of rulesync source-tree directories. Each entry
38856
+ * is a source tree itself — the directory that directly contains
38857
+ * feature subdirectories (`rules/`, `skills/`, …) and single-file
38858
+ * features (`mcp.jsonc`, `hooks.jsonc`, …). Later entries take precedence
38859
+ * when two trees supply the same relative path. Defaults to
38860
+ * `[join(process.cwd(), ".rulesync")]`.
38861
+ *
38862
+ * The singular user-facing alias (`inputRoot` in `rulesync.jsonc` / the
38863
+ * `--input-root` CLI flag / `GenerateOptions.inputRoot`) is deprecated
38864
+ * and collapsed into `[join(inputRoot, ".rulesync")]` before it ever
38865
+ * reaches a processor.
38866
+ */
38867
+ inputRoots;
37884
38868
  dryRun;
37885
38869
  avoidBlockScalars;
37886
38870
  logger;
37887
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
38871
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37888
38872
  this.outputRoot = outputRoot;
37889
- this.inputRoot = inputRoot;
38873
+ this.inputRoots = inputRoots !== void 0 && inputRoots.length > 0 ? [inputRoots[0], ...inputRoots.slice(1)] : [(0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH)];
37890
38874
  this.dryRun = dryRun;
37891
38875
  this.avoidBlockScalars = avoidBlockScalars;
37892
38876
  this.logger = logger;
@@ -40145,9 +41129,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
40145
41129
  * slug is owned by the commands feature: it must not be imported as a
40146
41130
  * skill nor deleted as an orphan skill.
40147
41131
  */
40148
- static async isDirOwned({ dirName, inputRoot }) {
41132
+ static async isDirOwned({ dirName, inputRoots }) {
40149
41133
  return !await rulesyncCommandSlugExists({
40150
- inputRoot,
41134
+ inputRoots,
40151
41135
  dirName
40152
41136
  });
40153
41137
  }
@@ -40197,7 +41181,11 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
40197
41181
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
40198
41182
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
40199
41183
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
40200
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
41184
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
41185
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
41186
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
41187
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
41188
+ version: zod_mini.z.optional(zod_mini.z.unknown())
40201
41189
  });
40202
41190
  /**
40203
41191
  * Represents a Factory Droid skill directory.
@@ -40250,16 +41238,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40250
41238
  };
40251
41239
  }
40252
41240
  toRulesyncSkill() {
40253
- const frontmatter = this.getFrontmatter();
40254
- const factorydroidBlock = {
40255
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
40256
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
40257
- ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
40258
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
40259
- };
41241
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
40260
41242
  const rulesyncFrontmatter = {
40261
- name: frontmatter.name,
40262
- description: frontmatter.description,
41243
+ name,
41244
+ description,
40263
41245
  targets: ["*"],
40264
41246
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
40265
41247
  };
@@ -40286,13 +41268,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
40286
41268
  rootFrontmatter: rulesyncFrontmatter,
40287
41269
  section: factorydroidSection
40288
41270
  });
41271
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
40289
41272
  const factorydroidFrontmatter = {
40290
41273
  name: rulesyncFrontmatter.name,
40291
41274
  description: rulesyncFrontmatter.description,
41275
+ ...section,
40292
41276
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
40293
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
40294
- ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
40295
- ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
41277
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
40296
41278
  };
40297
41279
  return new FactorydroidSkill({
40298
41280
  outputRoot,
@@ -40704,7 +41686,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40704
41686
  }
40705
41687
  }
40706
41688
  static getSettablePaths(_options) {
40707
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
41689
+ return {
41690
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
41691
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
41692
+ };
40708
41693
  }
40709
41694
  getFrontmatter() {
40710
41695
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -42866,9 +43851,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42866
43851
  * slug is owned by the commands feature: it must not be imported as a
42867
43852
  * skill nor deleted as an orphan skill.
42868
43853
  */
42869
- static async isDirOwned({ dirName, inputRoot }) {
43854
+ static async isDirOwned({ dirName, inputRoots }) {
42870
43855
  return !await rulesyncCommandSlugExists({
42871
- inputRoot,
43856
+ inputRoots,
42872
43857
  dirName
42873
43858
  });
42874
43859
  }
@@ -43426,10 +44411,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43426
44411
  toolTarget;
43427
44412
  global;
43428
44413
  getFactory;
43429
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
44414
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
43430
44415
  super({
43431
44416
  outputRoot,
43432
- inputRoot,
44417
+ inputRoots,
43433
44418
  dryRun,
43434
44419
  avoidBlockScalars: toolTarget === "cursor",
43435
44420
  logger
@@ -43467,39 +44452,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43467
44452
  return rulesyncSkills;
43468
44453
  }
43469
44454
  /**
43470
- * Implementation of abstract method from DirFeatureProcessor
43471
- * Load and parse rulesync skill directories from .rulesync/skills/ directory
43472
- * and also from .rulesync/skills/.curated/ for remote skills.
43473
- * Local skills take precedence over curated skills with the same name.
44455
+ * Load rulesync skill directories from a single source-tree's `skills/`
44456
+ * (and `skills/.curated/`) subtree. `sourceTree` is the source tree
44457
+ * itself (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`). Intra-tree:
44458
+ * local skills take precedence over curated skills with the same name.
43474
44459
  */
43475
- async loadRulesyncDirs() {
43476
- const localDirNames = [...await getLocalSkillDirNames(this.inputRoot)];
44460
+ async loadRulesyncDirsForRoot(sourceTree) {
44461
+ const treeParent = (0, node_path.dirname)(sourceTree);
44462
+ const treeName = (0, node_path.basename)(sourceTree);
44463
+ const treeSkillsDirPath = (0, node_path.join)(treeName, SKILLS_FEATURE_SUBDIR);
44464
+ const treeCuratedSkillsDirPath = (0, node_path.join)(treeName, CURATED_SKILLS_FEATURE_SUBDIR);
44465
+ const localDirNames = [...await getLocalSkillDirNames(sourceTree)];
43477
44466
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
43478
- outputRoot: this.inputRoot,
44467
+ outputRoot: treeParent,
44468
+ relativeDirPath: treeSkillsDirPath,
43479
44469
  dirName,
43480
44470
  global: this.global
43481
44471
  })));
43482
- const localSkillNames = new Set(localDirNames);
43483
- const curatedDirPath = (0, node_path.join)(this.inputRoot, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH);
44472
+ const localSkillNamesByIdentity = groupSpellingsByCaseFoldedIdentity(localDirNames);
44473
+ const curatedDirPath = (0, node_path.join)(sourceTree, CURATED_SKILLS_FEATURE_SUBDIR);
43484
44474
  let curatedSkills = [];
43485
44475
  if (await directoryExists(curatedDirPath)) {
43486
44476
  const nonConflicting = (await findFilesByGlobs((0, node_path.join)(curatedDirPath, "*"), { type: "dir" })).map((path) => (0, node_path.basename)(path)).filter((name) => {
43487
- if (localSkillNames.has(name)) {
43488
- this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
43489
- return false;
43490
- }
43491
- return true;
44477
+ const spellings = localSkillNamesByIdentity.get(caseFoldIdentity(name));
44478
+ if (spellings === void 0) return true;
44479
+ if (spellings.includes(name)) this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
44480
+ else this.logger.warn(formatCuratedCaseCollisionWarning({
44481
+ artifactKind: "skill",
44482
+ entryNoun: "skill",
44483
+ treeDirPath: treeSkillsDirPath,
44484
+ curatedSpelling: name,
44485
+ localSpellings: spellings
44486
+ }));
44487
+ return false;
43492
44488
  });
43493
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
43494
44489
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
43495
- outputRoot: this.inputRoot,
43496
- relativeDirPath: curatedRelativeDirPath,
44490
+ outputRoot: treeParent,
44491
+ relativeDirPath: treeCuratedSkillsDirPath,
43497
44492
  dirName,
43498
44493
  global: this.global
43499
44494
  })));
43500
44495
  }
43501
- const allSkills = [...localSkills, ...curatedSkills];
43502
- this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills (${localSkills.length} local, ${curatedSkills.length} curated)`);
44496
+ return [...localSkills, ...curatedSkills];
44497
+ }
44498
+ /**
44499
+ * Implementation of abstract method from DirFeatureProcessor.
44500
+ *
44501
+ * Load and parse rulesync skill directories from every configured input
44502
+ * root's `.rulesync/skills/` tree (each root also honours its own
44503
+ * `.curated/` subdirectory). When two roots supply a skill with the same
44504
+ * directory name, the later root's skill replaces the earlier root's copy
44505
+ * atomically (companion files included) — an overlay always ships a whole
44506
+ * skill directory, never a partial patch.
44507
+ */
44508
+ async loadRulesyncDirs() {
44509
+ const allSkills = mergeByCaseInsensitiveIdentity({
44510
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncDirsForRoot(root))),
44511
+ identity: (skill) => skill.getDirName(),
44512
+ artifactName: "skill",
44513
+ logger: this.logger
44514
+ });
44515
+ this.logger.debug(`Successfully loaded ${allSkills.length} rulesync skills`);
43503
44516
  return allSkills;
43504
44517
  }
43505
44518
  /**
@@ -43515,7 +44528,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43515
44528
  }) : [];
43516
44529
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
43517
44530
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
43518
- const seenSkillNames = /* @__PURE__ */ new Set();
44531
+ const claimedSkillNames = new ClaimedIdentities();
44532
+ const claimSkillName = ({ skill, relativeDirPath, sourcePath }) => {
44533
+ const skillName = skill.getImportIdentity();
44534
+ const claimed = claimedSkillNames.claim({
44535
+ identity: skillName,
44536
+ source: relativeDirPath
44537
+ });
44538
+ if (claimed === null) return true;
44539
+ 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.`);
44540
+ return false;
44541
+ };
43519
44542
  const toolSkills = [];
43520
44543
  for (const root of roots) {
43521
44544
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -43531,54 +44554,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43531
44554
  outputRoot: rootOutputRoot,
43532
44555
  relativeDirPath,
43533
44556
  dirName,
43534
- inputRoot: this.inputRoot
44557
+ inputRoots: this.inputRoots
43535
44558
  })) continue;
43536
44559
  ownedDirNames.push(dirName);
43537
44560
  }
43538
44561
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
44562
+ const sourcePath = (0, node_path.join)(relativeDirPath, dirName);
43539
44563
  try {
43540
- return await factory.class.fromDir({
43541
- outputRoot: rootOutputRoot,
43542
- relativeDirPath,
43543
- dirName,
43544
- global: this.global
43545
- });
44564
+ return {
44565
+ skill: await factory.class.fromDir({
44566
+ outputRoot: rootOutputRoot,
44567
+ relativeDirPath,
44568
+ dirName,
44569
+ global: this.global
44570
+ }),
44571
+ sourcePath
44572
+ };
43546
44573
  } catch (error) {
43547
44574
  if (!isLenientRoot) throw error;
43548
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, dirName)}: ${formatError(error)}`);
44575
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43549
44576
  return null;
43550
44577
  }
43551
- }))).filter((skill) => skill !== null);
43552
- for (const skill of directorySkills) {
43553
- const skillName = skill.getImportIdentity();
43554
- if (seenSkillNames.has(skillName)) continue;
43555
- seenSkillNames.add(skillName);
43556
- toolSkills.push(skill);
43557
- }
44578
+ }))).filter((loaded) => loaded !== null);
44579
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
44580
+ skill,
44581
+ relativeDirPath,
44582
+ sourcePath
44583
+ })) toolSkills.push(skill);
43558
44584
  if (!factory.class.fromFlatFile) continue;
43559
44585
  const fromFlatFile = factory.class.fromFlatFile;
43560
44586
  const directoryStems = new Set(ownedDirNames);
43561
44587
  const flatFilePaths = (await findFilesByGlobs((0, node_path.join)(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has((0, node_path.basename)(filePath, ".md")));
43562
44588
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
44589
+ const sourcePath = (0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath));
43563
44590
  try {
43564
- return await fromFlatFile({
43565
- outputRoot: rootOutputRoot,
43566
- relativeDirPath,
43567
- relativeFilePath: (0, node_path.basename)(filePath),
43568
- global: this.global
43569
- });
44591
+ return {
44592
+ skill: await fromFlatFile({
44593
+ outputRoot: rootOutputRoot,
44594
+ relativeDirPath,
44595
+ relativeFilePath: (0, node_path.basename)(filePath),
44596
+ global: this.global
44597
+ }),
44598
+ sourcePath
44599
+ };
43570
44600
  } catch (error) {
43571
44601
  if (!isLenientRoot) throw error;
43572
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath))}: ${formatError(error)}`);
44602
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43573
44603
  return null;
43574
44604
  }
43575
- }))).filter((skill) => skill !== null);
43576
- for (const skill of flatSkills) {
43577
- const skillName = skill.getImportIdentity();
43578
- if (seenSkillNames.has(skillName)) continue;
43579
- seenSkillNames.add(skillName);
43580
- toolSkills.push(skill);
43581
- }
44605
+ }))).filter((loaded) => loaded !== null);
44606
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
44607
+ skill,
44608
+ relativeDirPath,
44609
+ sourcePath
44610
+ })) toolSkills.push(skill);
43582
44611
  }
43583
44612
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43584
44613
  return toolSkills;
@@ -43608,7 +44637,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43608
44637
  outputRoot: this.outputRoot,
43609
44638
  relativeDirPath: root,
43610
44639
  dirName,
43611
- inputRoot: this.inputRoot
44640
+ inputRoots: this.inputRoots
43612
44641
  })) continue;
43613
44642
  const toolSkill = factory.class.forDeletion({
43614
44643
  outputRoot: this.outputRoot,
@@ -46366,6 +47395,7 @@ const JunieSubagentFrontmatterSchema = zod_mini.z.looseObject({
46366
47395
  disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
46367
47396
  mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
46368
47397
  model: zod_mini.z.optional(zod_mini.z.string()),
47398
+ permissionMode: zod_mini.z.optional(zod_mini.z.string()),
46369
47399
  reasoningLevel: zod_mini.z.optional(zod_mini.z.string()),
46370
47400
  maxTurns: zod_mini.z.optional(zod_mini.z.number()),
46371
47401
  skills: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
@@ -48346,14 +49376,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
48346
49376
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
48347
49377
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
48348
49378
  });
49379
+ /**
49380
+ * Stands in for a discovery root when a subagent came from a tool's own config
49381
+ * file rather than a directory (see `loadAdditionalImportFiles`). The angle
49382
+ * brackets keep it from ever matching a real relative directory path.
49383
+ */
49384
+ const INLINE_SOURCE = "<inline>";
49385
+ /**
49386
+ * The single "root" of the post-conversion output guard, which de-duplicates
49387
+ * `.rulesync/subagents/` paths rather than discovery roots.
49388
+ */
49389
+ const OUTPUT_SOURCE = "<output>";
48349
49390
  var SubagentsProcessor = class extends FeatureProcessor {
48350
49391
  toolTarget;
48351
49392
  global;
48352
49393
  getFactory;
48353
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
49394
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
48354
49395
  super({
48355
49396
  outputRoot,
48356
- inputRoot,
49397
+ inputRoots,
48357
49398
  dryRun,
48358
49399
  logger
48359
49400
  });
@@ -48399,24 +49440,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
48399
49440
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
48400
49441
  }
48401
49442
  const uniqueRulesyncSubagents = [];
48402
- const seenOutputPaths = /* @__PURE__ */ new Set();
49443
+ const claimedOutputPaths = new ClaimedIdentities();
48403
49444
  for (const rulesyncSubagent of rulesyncSubagents) {
48404
49445
  const outputPath = (0, node_path.join)(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
48405
- if (seenOutputPaths.has(outputPath)) {
48406
- this.logger.warn(`Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.`);
49446
+ const claimed = claimedOutputPaths.claim({
49447
+ identity: outputPath,
49448
+ source: OUTPUT_SOURCE
49449
+ });
49450
+ if (claimed !== null) {
49451
+ this.logger.warn(claimed.spelling === outputPath ? `Multiple ${this.toolTarget} subagents resolve to "${outputPath}"; keeping the first and ignoring this copy.` : `${this.toolTarget} subagent "${outputPath}" differs only in case from "${claimed.spelling}", which is the same file on a case-insensitive filesystem; keeping the first and ignoring this copy.`);
48407
49452
  continue;
48408
49453
  }
48409
- seenOutputPaths.add(outputPath);
48410
49454
  uniqueRulesyncSubagents.push(rulesyncSubagent);
48411
49455
  }
48412
49456
  return uniqueRulesyncSubagents;
48413
49457
  }
48414
49458
  /**
48415
- * Implementation of abstract method from Processor
48416
- * Load and parse rulesync subagent files from .rulesync/subagents/ directory
49459
+ * Load subagent files from a single source-tree's `subagents/` subtree.
49460
+ * `sourceTree` is the source tree itself (e.g. `/repo/.rulesync` or
49461
+ * `/repo/.rulesync.local`).
48417
49462
  */
48418
- async loadRulesyncFiles() {
48419
- const subagentsDir = (0, node_path.join)(this.inputRoot, RulesyncSubagent.getSettablePaths().relativeDirPath);
49463
+ async loadRulesyncFilesForRoot(sourceTree) {
49464
+ const treeParent = (0, node_path.dirname)(sourceTree);
49465
+ const treeName = (0, node_path.basename)(sourceTree);
49466
+ const treeSubagentsDirPath = (0, node_path.join)(treeName, SUBAGENTS_FEATURE_SUBDIR);
49467
+ const subagentsDir = (0, node_path.join)(sourceTree, SUBAGENTS_FEATURE_SUBDIR);
48420
49468
  if (!await directoryExists(subagentsDir)) {
48421
49469
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
48422
49470
  return [];
@@ -48432,7 +49480,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
48432
49480
  const filepath = (0, node_path.join)(subagentsDir, mdFile);
48433
49481
  try {
48434
49482
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
48435
- outputRoot: this.inputRoot,
49483
+ outputRoot: treeParent,
49484
+ relativeDirPath: treeSubagentsDirPath,
48436
49485
  relativeFilePath: mdFile,
48437
49486
  validate: true
48438
49487
  });
@@ -48443,8 +49492,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
48443
49492
  continue;
48444
49493
  }
48445
49494
  }
49495
+ return rulesyncSubagents;
49496
+ }
49497
+ /**
49498
+ * Implementation of abstract method from Processor
49499
+ * Load and parse rulesync subagent files from every configured input root's
49500
+ * `.rulesync/subagents/` directory, merging by relative file path so a
49501
+ * subagent with the same target path from a later root replaces the
49502
+ * earlier root's copy.
49503
+ */
49504
+ async loadRulesyncFiles() {
49505
+ const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
49506
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
49507
+ identity: (subagent) => subagent.getRelativeFilePath(),
49508
+ artifactName: "subagent",
49509
+ logger: this.logger
49510
+ });
48446
49511
  if (rulesyncSubagents.length === 0) {
48447
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
49512
+ this.logger.debug(`No valid subagents found`);
48448
49513
  return [];
48449
49514
  }
48450
49515
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -48459,7 +49524,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
48459
49524
  const paths = factory.class.getSettablePaths({ global: this.global });
48460
49525
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
48461
49526
  const toolSubagents = [];
48462
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
49527
+ const claimedRelativeFilePaths = new ClaimedIdentities();
48463
49528
  for (const root of roots) {
48464
49529
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
48465
49530
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -48498,37 +49563,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
48498
49563
  relativeFilePath: toRelativeFilePath(path),
48499
49564
  global: this.global
48500
49565
  })));
48501
- const deduped = [];
48502
- for (const subagent of loaded) {
48503
- const key = subagent.getImportIdentity();
48504
- if (seenRelativeFilePaths.has(key)) {
48505
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping the one from a higher-precedence directory and ignoring this copy.`);
48506
- continue;
48507
- }
48508
- seenRelativeFilePaths.add(key);
48509
- deduped.push(subagent);
48510
- }
48511
- toolSubagents.push(...deduped);
49566
+ toolSubagents.push(...this.claimStandaloneSubagents({
49567
+ loaded,
49568
+ dirPath,
49569
+ claimedRelativeFilePaths
49570
+ }));
48512
49571
  }
48513
49572
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
48514
49573
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
48515
49574
  outputRoot: this.outputRoot,
48516
49575
  global: this.global
48517
49576
  });
48518
- for (const subagent of additionalSubagents) {
48519
- const key = subagent.getImportIdentity();
48520
- if (seenRelativeFilePaths.has(key)) {
48521
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping the standalone file and ignoring the inline copy.`);
48522
- continue;
48523
- }
48524
- seenRelativeFilePaths.add(key);
48525
- toolSubagents.push(subagent);
48526
- }
49577
+ toolSubagents.push(...this.claimInlineSubagents({
49578
+ additionalSubagents,
49579
+ claimedRelativeFilePaths
49580
+ }));
48527
49581
  }
48528
49582
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
48529
49583
  return toolSubagents;
48530
49584
  }
48531
49585
  /**
49586
+ * Keeps the subagents from one discovery root whose import identity is still
49587
+ * unclaimed, warning about each copy that loses. Split out of
49588
+ * `loadToolFiles` so the two de-duplication passes stay readable side by
49589
+ * side (and so that method stays within the linter's complexity budget).
49590
+ *
49591
+ * When more than one discovery root is scanned (e.g. Junie's `.junie/agents/`
49592
+ * plus `.agents/`), two roots can hold a subagent with the same relative
49593
+ * path. Downstream conversion keys by that path, so a later one would
49594
+ * silently overwrite an earlier one. Warn instead of failing, keeping the
49595
+ * earlier (higher-precedence) root's file.
49596
+ */
49597
+ claimStandaloneSubagents({ loaded, dirPath, claimedRelativeFilePaths }) {
49598
+ const deduped = [];
49599
+ for (const subagent of loaded) {
49600
+ const key = subagent.getImportIdentity();
49601
+ const claimed = claimedRelativeFilePaths.claim({
49602
+ identity: key,
49603
+ source: dirPath
49604
+ });
49605
+ if (claimed === null) {
49606
+ deduped.push(subagent);
49607
+ continue;
49608
+ }
49609
+ const keptFrom = claimed.source === dirPath ? `the earlier one in ${dirPath}` : `the one from the higher-precedence ${claimed.source}`;
49610
+ 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.`);
49611
+ }
49612
+ return deduped;
49613
+ }
49614
+ /**
49615
+ * The same claim-or-warn pass for subagents defined inline in a tool's own
49616
+ * config file (see `loadAdditionalImportFiles`), which are scanned after
49617
+ * every standalone file so a Markdown file of the same name wins.
49618
+ */
49619
+ claimInlineSubagents({ additionalSubagents, claimedRelativeFilePaths }) {
49620
+ const deduped = [];
49621
+ for (const subagent of additionalSubagents) {
49622
+ const key = subagent.getImportIdentity();
49623
+ const claimed = claimedRelativeFilePaths.claim({
49624
+ identity: key,
49625
+ source: INLINE_SOURCE
49626
+ });
49627
+ if (claimed === null) {
49628
+ deduped.push(subagent);
49629
+ continue;
49630
+ }
49631
+ const kept = claimed.source === INLINE_SOURCE ? "the earlier inline definition" : `the standalone file in ${claimed.source}`;
49632
+ 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.`);
49633
+ }
49634
+ return deduped;
49635
+ }
49636
+ /**
48532
49637
  * Implementation of abstract method from FeatureProcessor
48533
49638
  * Return the tool targets that this processor supports
48534
49639
  */
@@ -51458,17 +52563,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
51458
52563
  * Rule generator for JetBrains Junie AI coding agent
51459
52564
  *
51460
52565
  * Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
51461
- * resolves project guidelines **first-match-wins**: `.junie/AGENTS.md` → root
51462
- * `AGENTS.md` legacy `.junie/guidelines.md` / `.junie/guidelines/`. Only the
51463
- * first match is loaded, it documents no `@`-reference or file-inclusion
51464
- * mechanism, and no `.junie/memories/` read path exists so non-root rules
51465
- * are folded into the single root `.junie/AGENTS.md` by the RulesProcessor
51466
- * (`nonRoot` is `undefined`, mirroring the warp / deepagents
51467
- * targets; decision recorded in issue #2211). The legacy
52566
+ * resolves project guidelines in this order: `.junie/AGENTS.md` → root
52567
+ * `AGENTS.md` **combined with `.junie/playbook.md` and every
52568
+ * `.junie/rules/*.md`** legacy `.junie/guidelines.md` / `.junie/guidelines/`.
52569
+ * The multi-file branch exists, but it is unreachable while `.junie/AGENTS.md`
52570
+ * is present: that file "is used exclusively and no other guidelines files are
52571
+ * combined with it". So emitting `.junie/rules/*.md` next to the root file
52572
+ * rulesync writes would produce files Junie never reads, and moving the root
52573
+ * output to project-root `AGENTS.md` would both change every existing output
52574
+ * path and collide with the `agentsmd` target. Non-root rules therefore stay
52575
+ * folded into the single root `.junie/AGENTS.md` by the RulesProcessor
52576
+ * (`nonRoot` is `undefined`, mirroring the warp / deepagents targets) — the
52577
+ * fold is lossless, since Junie loads that one file in full. The original
52578
+ * rationale for this shape was recorded in issue #2211 and re-confirmed
52579
+ * against the 2026-08-21 docs revision in issue #2728. The legacy
51468
52580
  * `.junie/guidelines.md` is still accepted as an import fallback, but
51469
52581
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
51470
52582
  * without frontmatter requirements.
51471
52583
  *
52584
+ * The multi-file branch is still read on **import**, though: a repo that
52585
+ * authors `.junie/rules/*.md` or `.junie/playbook.md` by hand — the live
52586
+ * layout whenever `.junie/AGENTS.md` is absent, which is exactly the state a
52587
+ * first `rulesync import` finds — declares those paths as `importOnlyRoots`
52588
+ * and imports them as non-root rules. They are never written back there:
52589
+ * generating `.junie/AGENTS.md` moves Junie onto the first branch, where the
52590
+ * folded root file carries the same content.
52591
+ *
51472
52592
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
51473
52593
  * these user-scope guidelines with the project guidelines (both are included
51474
52594
  * and marked clearly).
@@ -51490,6 +52610,14 @@ var JunieRule = class JunieRule extends ToolRule {
51490
52610
  alternativeRoots: [{
51491
52611
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
51492
52612
  relativeFilePath: JUNIE_LEGACY_RULE_FILE_NAME
52613
+ }],
52614
+ importOnlyRoots: [{
52615
+ relativeDirPath: buildToolPath(JUNIE_DIR, JUNIE_RULES_DIR_NAME, excludeToolDir),
52616
+ onlyWhenRootAbsent: true
52617
+ }, {
52618
+ relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
52619
+ relativeFilePath: JUNIE_PLAYBOOK_FILE_NAME,
52620
+ onlyWhenRootAbsent: true
51493
52621
  }]
51494
52622
  };
51495
52623
  }
@@ -51500,7 +52628,7 @@ var JunieRule = class JunieRule extends ToolRule {
51500
52628
  static isRootRelativeFilePath(relativeFilePath) {
51501
52629
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
51502
52630
  }
51503
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
52631
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath: relativeDirPathParam, relativeFilePath, validate = true, global = false }) {
51504
52632
  if (global) {
51505
52633
  const paths = this.getSettablePaths({ global: true });
51506
52634
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -51514,7 +52642,8 @@ var JunieRule = class JunieRule extends ToolRule {
51514
52642
  root: true
51515
52643
  });
51516
52644
  }
51517
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
52645
+ const settablePaths = this.getSettablePaths();
52646
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
51518
52647
  const relativePath = (0, node_path.join)(relativeDirPath, relativeFilePath);
51519
52648
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
51520
52649
  return new JunieRule({
@@ -51523,7 +52652,7 @@ var JunieRule = class JunieRule extends ToolRule {
51523
52652
  relativeFilePath,
51524
52653
  fileContent,
51525
52654
  validate,
51526
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
52655
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
51527
52656
  });
51528
52657
  }
51529
52658
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53875,10 +55004,23 @@ const defaultGetFactory = (target) => {
53875
55004
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53876
55005
  return factory;
53877
55006
  };
53878
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53879
- const primaryFilePaths = await findFilesByGlobs(primaryGlob);
55007
+ /**
55008
+ * How many skipped import-only paths a single warning names before it
55009
+ * summarizes the rest. Keeps one line readable when a rules directory holds
55010
+ * dozens of files.
55011
+ */
55012
+ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
55013
+ /**
55014
+ * Fall back to a tool's legacy roots when its primary root file is absent.
55015
+ *
55016
+ * The primary hits are passed in rather than globbed here, so that callers
55017
+ * which need "the root file Rulesync generates" — rather than "whatever root
55018
+ * the tool will read" — can keep the two apart. A legacy root is a file
55019
+ * Rulesync reads but never writes, and the difference matters to them.
55020
+ */
55021
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
53880
55022
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53881
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
55023
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53882
55024
  return [];
53883
55025
  };
53884
55026
  var RulesProcessor = class extends FeatureProcessor {
@@ -53890,10 +55032,10 @@ var RulesProcessor = class extends FeatureProcessor {
53890
55032
  getFactory;
53891
55033
  skills;
53892
55034
  featureOptions;
53893
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
55035
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
53894
55036
  super({
53895
55037
  outputRoot,
53896
- inputRoot,
55038
+ inputRoots,
53897
55039
  dryRun,
53898
55040
  logger
53899
55041
  });
@@ -54268,25 +55410,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
54268
55410
  claimedBy.set(target.toLowerCase(), source);
54269
55411
  continue;
54270
55412
  }
54271
- this.logger.warn(`Both ${previous} and ${source} import to ${(0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
55413
+ this.logger.warn(`Both ${stripControlCharacters(previous)} and ${stripControlCharacters(source)} import to ${stripControlCharacters((0, node_path.join)(RULESYNC_RULES_RELATIVE_DIR_PATH, target))} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`);
54272
55414
  }
54273
55415
  return rulesyncRules;
54274
55416
  }
54275
55417
  /**
54276
- * Implementation of abstract method from FeatureProcessor
54277
- * Load and parse rulesync rule files from .rulesync/rules/ directory
55418
+ * Load rulesync rule files from a single source-tree's `rules/` (and
55419
+ * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
55420
+ * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
55421
+ *
55422
+ * Intra-tree behavior — the local-wins-over-curated rule and the
55423
+ * case-insensitive collision handling — is preserved from the previous
55424
+ * single-root implementation. See `loadRulesyncFiles` for how the
55425
+ * per-root results are combined into the effective set.
54278
55426
  */
54279
- async loadRulesyncFiles() {
54280
- const rulesyncOutputRoot = (0, node_path.join)(this.inputRoot, RULESYNC_RULES_RELATIVE_DIR_PATH);
54281
- const curatedOutputRoot = (0, node_path.join)(this.inputRoot, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH);
55427
+ async loadRulesyncFilesForRoot(sourceTree) {
55428
+ const treeParent = (0, node_path.dirname)(sourceTree);
55429
+ const treeName = (0, node_path.basename)(sourceTree);
55430
+ const treeRulesDirPath = (0, node_path.join)(treeName, RULES_FEATURE_SUBDIR);
55431
+ const rulesyncOutputRoot = (0, node_path.join)(sourceTree, RULES_FEATURE_SUBDIR);
55432
+ const curatedOutputRoot = (0, node_path.join)(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
54282
55433
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md"))]);
54283
55434
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
54284
55435
  const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
54285
- const localRelativePaths = new Set(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
55436
+ const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => (0, node_path.relative)(rulesyncOutputRoot, file)));
54286
55437
  const curatedFiles = files.filter((file) => (0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`)).map((file) => ({
54287
55438
  file,
54288
55439
  relativeFilePath: (0, node_path.relative)(curatedOutputRoot, file)
54289
- })).filter(({ relativeFilePath }) => !localRelativePaths.has(relativeFilePath));
55440
+ })).filter(({ relativeFilePath }) => {
55441
+ const spellings = localRelativePathsByIdentity.get(caseFoldIdentity(relativeFilePath));
55442
+ if (spellings === void 0) return true;
55443
+ if (!spellings.includes(relativeFilePath)) this.logger.warn(formatCuratedCaseCollisionWarning({
55444
+ artifactKind: "rule",
55445
+ entryNoun: "file",
55446
+ treeDirPath: treeRulesDirPath,
55447
+ curatedSpelling: (0, node_path.join)(".curated", relativeFilePath),
55448
+ localSpellings: spellings
55449
+ }));
55450
+ return false;
55451
+ });
54290
55452
  const selectedFiles = [...localFiles.map((file) => ({
54291
55453
  file,
54292
55454
  sourceRelativeFilePath: (0, node_path.relative)(rulesyncOutputRoot, file),
@@ -54296,25 +55458,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
54296
55458
  sourceRelativeFilePath: (0, node_path.join)(".curated", relativeFilePath),
54297
55459
  relativeFilePath
54298
55460
  }))];
54299
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
54300
- const rulesyncRules = await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
55461
+ this.logger.debug(`Found ${selectedFiles.length} rulesync files under ${rulesyncOutputRoot}`);
55462
+ return await Promise.all(selectedFiles.map(async ({ sourceRelativeFilePath, relativeFilePath }) => {
54301
55463
  checkPathTraversal({
54302
55464
  relativePath: sourceRelativeFilePath,
54303
55465
  intendedRootDir: rulesyncOutputRoot
54304
55466
  });
54305
55467
  const rule = await RulesyncRule.fromFile({
54306
- outputRoot: this.inputRoot,
55468
+ outputRoot: treeParent,
55469
+ relativeDirPath: treeRulesDirPath,
54307
55470
  relativeFilePath: sourceRelativeFilePath
54308
55471
  });
54309
55472
  if (sourceRelativeFilePath === relativeFilePath) return rule;
54310
55473
  return new RulesyncRule({
54311
- outputRoot: this.inputRoot,
54312
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
55474
+ outputRoot: treeParent,
55475
+ relativeDirPath: treeRulesDirPath,
54313
55476
  relativeFilePath,
54314
55477
  frontmatter: rule.getFrontmatter(),
54315
55478
  body: rule.getBody()
54316
55479
  });
54317
55480
  }));
55481
+ }
55482
+ /**
55483
+ * Implementation of abstract method from FeatureProcessor
55484
+ * Load and parse rulesync rule files from every configured input root's
55485
+ * `.rulesync/rules/` directory, merging by relative path so that a rule
55486
+ * with the same target path from a later root replaces the earlier root's
55487
+ * copy (case-insensitive, matching the intra-root collision handling).
55488
+ */
55489
+ async loadRulesyncFiles() {
55490
+ const rulesyncRules = mergeByCaseInsensitiveIdentity({
55491
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
55492
+ identity: (rule) => rule.getRelativeFilePath(),
55493
+ artifactName: "rule",
55494
+ logger: this.logger
55495
+ });
54318
55496
  const factory = this.getFactory(this.toolTarget);
54319
55497
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
54320
55498
  if (targetedRootRules.length === 0 && rulesyncRules.length > 0) this.logger.warn(`No root rulesync rule file found for target '${this.toolTarget}'. Consider adding 'root: true' to one of your rule files in ${RULESYNC_RULES_RELATIVE_DIR_PATH}.`);
@@ -54408,23 +55586,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
54408
55586
  });
54409
55587
  }).filter((rule) => rule.isDeletable());
54410
55588
  };
55589
+ /**
55590
+ * Import counterpart of {@link buildDeletionRulesFromPaths} for the
55591
+ * root-shaped scans (root, legacy roots, and read-only roots), whose
55592
+ * paths all sit at a directory Rulesync resolves from the file itself.
55593
+ */
55594
+ const buildImportRulesFromPaths = (filePaths) => Promise.all(filePaths.map((filePath) => {
55595
+ const relativeDirPath = resolveRelativeDirPath(filePath);
55596
+ checkPathTraversal({
55597
+ relativePath: relativeDirPath,
55598
+ intendedRootDir: this.outputRoot
55599
+ });
55600
+ return factory.class.fromFile({
55601
+ outputRoot: this.outputRoot,
55602
+ relativeDirPath,
55603
+ relativeFilePath: (0, node_path.basename)(filePath),
55604
+ global: this.global
55605
+ });
55606
+ }));
55607
+ /**
55608
+ * The tool's own root file, as opposed to whichever root
55609
+ * `rootToolRules` ends up resolving. A legacy root reached through
55610
+ * `alternativeRoots` is deliberately not counted here: it is a
55611
+ * hand-authored file Rulesync never writes, so it has folded nothing in,
55612
+ * and in Junie's resolution order it ranks *below* the multi-file layout
55613
+ * that `importOnlyRoots` describes. Gating those roots on it would drop
55614
+ * exactly the files the tool is really reading.
55615
+ *
55616
+ * Resolved once, up front, so the two blocks that need it do not depend
55617
+ * on each other's evaluation order.
55618
+ */
55619
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
54411
55620
  const rootToolRules = await (async () => {
54412
55621
  if (!settablePaths.root) return [];
54413
- const uniqueRootFilePaths = await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
55622
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
54414
55623
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
54415
- return await Promise.all(uniqueRootFilePaths.map((filePath) => {
54416
- const relativeDirPath = resolveRelativeDirPath(filePath);
54417
- checkPathTraversal({
54418
- relativePath: relativeDirPath,
54419
- intendedRootDir: this.outputRoot
54420
- });
54421
- return factory.class.fromFile({
54422
- outputRoot: this.outputRoot,
54423
- relativeFilePath: (0, node_path.basename)(filePath),
54424
- relativeDirPath,
54425
- global: this.global
54426
- });
54427
- }));
55624
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
54428
55625
  })();
54429
55626
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
54430
55627
  const localRootToolRules = await (async () => {
@@ -54436,7 +55633,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54436
55633
  fileName
54437
55634
  }));
54438
55635
  if (!settablePaths.root) return [];
54439
- return await findFilesWithFallback((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
55636
+ return await findFilesWithFallback(await findFilesByGlobs((0, node_path.join)(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => (0, node_path.join)(this.outputRoot, alt.relativeDirPath, fileName));
54440
55637
  })();
54441
55638
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
54442
55639
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -54512,6 +55709,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
54512
55709
  }));
54513
55710
  })();
54514
55711
  this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`);
55712
+ const importOnlyToolRules = await (async () => {
55713
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
55714
+ if (forDeletion || !importOnlyRoots || importOnlyRoots.length === 0) return [];
55715
+ const rootFilePath = primaryRootFilePaths[0];
55716
+ const scannedPaths = [];
55717
+ const skippedPaths = [];
55718
+ for (const importOnlyRoot of importOnlyRoots) {
55719
+ const matchedPaths = await findFilesByGlobs((0, node_path.join)(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
55720
+ if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
55721
+ skippedPaths.push(...matchedPaths);
55722
+ continue;
55723
+ }
55724
+ scannedPaths.push(...matchedPaths);
55725
+ }
55726
+ if (skippedPaths.length > 0 && rootFilePath !== void 0) {
55727
+ const skippedNames = skippedPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
55728
+ const listedNames = skippedNames.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
55729
+ const remainingCount = skippedNames.length - listedNames.length;
55730
+ this.logger.warn(`Not importing ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} for ${this.toolTarget}: ${stripControlCharacters((0, node_path.relative)(this.outputRoot, rootFilePath))} exists, and the tool reads that file exclusively. Delete them once you have checked that content is in the root file, or move it into ${RULESYNC_RULES_RELATIVE_DIR_PATH} if it is not.`);
55731
+ }
55732
+ return await buildImportRulesFromPaths(scannedPaths);
55733
+ })();
55734
+ this.logger.debug(`Found ${importOnlyToolRules.length} import-only tool rule files`);
54515
55735
  const nonRootToolRules = await (async () => {
54516
55736
  if (!settablePaths.nonRoot) return [];
54517
55737
  const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54544,6 +55764,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54544
55764
  })();
54545
55765
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54546
55766
  return [
55767
+ ...importOnlyToolRules,
54547
55768
  ...rootToolRules,
54548
55769
  ...localRootToolRules,
54549
55770
  ...rootMirrorDeletionRules,
@@ -55351,14 +56572,53 @@ function warnUnsupportedTargets(params) {
55351
56572
  }
55352
56573
  }
55353
56574
  /**
55354
- * Check if .rulesync directory exists.
55355
- *
55356
- * The `.rulesync/` directory lives under the *input* root (where source rules
55357
- * are read from), not under any individual output root, so callers always pass
55358
- * `config.getInputRoot()` here.
56575
+ * Inspect every configured input-root path. The first entry is the required
56576
+ * base source tree; later entries are optional overlays and may be absent.
56577
+ * Each existing entry is a rulesync source tree itself (the directory that
56578
+ * directly holds `rules/`, `skills/`, `mcp.jsonc`, etc.). Existing empty
56579
+ * directories are valid because delete and check workflows still need to
56580
+ * inspect generated outputs.
55359
56581
  */
55360
- async function checkRulesyncDirExists(params) {
55361
- return fileExists((0, node_path.join)(params.inputRoot, RULESYNC_RELATIVE_DIR_PATH));
56582
+ async function inspectInputRoots(inputRoots) {
56583
+ const existing = [];
56584
+ const missing = [];
56585
+ const invalidOverlays = [];
56586
+ const nonDirectories = /* @__PURE__ */ new Set();
56587
+ for (const [index, root] of inputRoots.entries()) if (await directoryExists(root)) existing.push(root);
56588
+ else {
56589
+ missing.push(root);
56590
+ if (await fileExists(root)) {
56591
+ nonDirectories.add(root);
56592
+ if (index > 0) invalidOverlays.push(root);
56593
+ }
56594
+ }
56595
+ const primaryRoot = inputRoots[0];
56596
+ const displayPrimaryRoot = stripControlCharacters(primaryRoot ?? "");
56597
+ if (primaryRoot === void 0 || existing.includes(primaryRoot)) {
56598
+ const invalidOverlay = invalidOverlays[0];
56599
+ return {
56600
+ existing,
56601
+ missing,
56602
+ message: invalidOverlay === void 0 ? void 0 : `Configured optional input root '${stripControlCharacters(invalidOverlay)}' exists but is not a directory.`
56603
+ };
56604
+ }
56605
+ const defaultRoot = (0, node_path.join)(process.cwd(), RULESYNC_RELATIVE_DIR_PATH);
56606
+ if (primaryRoot === defaultRoot && !nonDirectories.has(primaryRoot)) return {
56607
+ existing,
56608
+ missing,
56609
+ message: `Rulesync source directory '${defaultRoot}' does not exist. Run 'rulesync init' first.`
56610
+ };
56611
+ const settingHint = `your input root setting ('inputRoots', or the deprecated 'inputRoot')`;
56612
+ if (nonDirectories.has(primaryRoot)) return {
56613
+ existing,
56614
+ missing,
56615
+ message: `Configured primary input root '${displayPrimaryRoot}' exists but is not a directory. Point ${settingHint} at a directory.`
56616
+ };
56617
+ return {
56618
+ existing,
56619
+ missing,
56620
+ message: `Configured primary input root '${displayPrimaryRoot}' does not exist. Create the directory or update ${settingHint}.`
56621
+ };
55362
56622
  }
55363
56623
  function dependsOnReachable(byId, from, target) {
55364
56624
  const seen = /* @__PURE__ */ new Set();
@@ -55489,7 +56749,7 @@ async function warnSkillSubagentNameCollisions(params) {
55489
56749
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
55490
56750
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
55491
56751
  const subagentsProcessor = new SubagentsProcessor({
55492
- inputRoot: config.getInputRoot(),
56752
+ inputRoots: config.getInputRoots(),
55493
56753
  toolTarget,
55494
56754
  global,
55495
56755
  logger
@@ -55497,7 +56757,7 @@ async function warnSkillSubagentNameCollisions(params) {
55497
56757
  const subagentNames = new Set((await subagentsProcessor.loadRulesyncFiles()).filter((file) => file instanceof RulesyncSubagent).filter((file) => subagentFactory.class.isTargetedByRulesyncSubagent(file)).map((file) => (0, node_path.basename)(file.getRelativeFilePath(), (0, node_path.extname)(file.getRelativeFilePath()))));
55498
56758
  if (subagentNames.size === 0) continue;
55499
56759
  const skillNames = (await new SkillsProcessor({
55500
- inputRoot: config.getInputRoot(),
56760
+ inputRoots: config.getInputRoots(),
55501
56761
  toolTarget,
55502
56762
  global,
55503
56763
  logger
@@ -55546,6 +56806,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55546
56806
  */
55547
56807
  async function generate(params) {
55548
56808
  const { config, logger } = params;
56809
+ resetRootShadowingWarnings({ logger });
55549
56810
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55550
56811
  toolTarget,
55551
56812
  outputRoot
@@ -55684,7 +56945,7 @@ async function generateRulesCore(params) {
55684
56945
  toolTarget,
55685
56946
  global: config.getGlobal()
55686
56947
  }),
55687
- inputRoot: config.getInputRoot(),
56948
+ inputRoots: config.getInputRoots(),
55688
56949
  toolTarget,
55689
56950
  global: config.getGlobal(),
55690
56951
  simulateCommands: config.getSimulateCommands(),
@@ -55734,7 +56995,7 @@ async function generateIgnoreCore(params) {
55734
56995
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55735
56996
  const processor = new IgnoreProcessor({
55736
56997
  outputRoot,
55737
- inputRoot: config.getInputRoot(),
56998
+ inputRoots: config.getInputRoots(),
55738
56999
  toolTarget,
55739
57000
  global,
55740
57001
  dryRun: config.isPreviewMode(),
@@ -55781,7 +57042,7 @@ async function generateMcpCore(params) {
55781
57042
  toolTarget,
55782
57043
  global: config.getGlobal()
55783
57044
  }),
55784
- inputRoot: config.getInputRoot(),
57045
+ inputRoots: config.getInputRoots(),
55785
57046
  toolTarget,
55786
57047
  global: config.getGlobal(),
55787
57048
  dryRun: config.isPreviewMode(),
@@ -55827,7 +57088,7 @@ async function generateCommandsCore(params) {
55827
57088
  toolTarget,
55828
57089
  global: config.getGlobal()
55829
57090
  }),
55830
- inputRoot: config.getInputRoot(),
57091
+ inputRoots: config.getInputRoots(),
55831
57092
  toolTarget,
55832
57093
  global: config.getGlobal(),
55833
57094
  dryRun: config.isPreviewMode(),
@@ -55874,7 +57135,7 @@ async function generateSubagentsCore(params) {
55874
57135
  toolTarget,
55875
57136
  global: config.getGlobal()
55876
57137
  }),
55877
- inputRoot: config.getInputRoot(),
57138
+ inputRoots: config.getInputRoots(),
55878
57139
  toolTarget,
55879
57140
  global: config.getGlobal(),
55880
57141
  dryRun: config.isPreviewMode(),
@@ -55921,7 +57182,7 @@ async function generateSkillsCore(params) {
55921
57182
  toolTarget,
55922
57183
  global: config.getGlobal()
55923
57184
  }),
55924
- inputRoot: config.getInputRoot(),
57185
+ inputRoots: config.getInputRoots(),
55925
57186
  toolTarget,
55926
57187
  global: config.getGlobal(),
55927
57188
  dryRun: config.isPreviewMode(),
@@ -55966,7 +57227,7 @@ async function generateHooksCore(params) {
55966
57227
  toolTarget,
55967
57228
  global: config.getGlobal()
55968
57229
  }),
55969
- inputRoot: config.getInputRoot(),
57230
+ inputRoots: config.getInputRoots(),
55970
57231
  toolTarget,
55971
57232
  global: config.getGlobal(),
55972
57233
  dryRun: config.isPreviewMode(),
@@ -56008,7 +57269,7 @@ async function generatePermissionsCore(params) {
56008
57269
  toolTarget,
56009
57270
  global: config.getGlobal()
56010
57271
  }),
56011
- inputRoot: config.getInputRoot(),
57272
+ inputRoots: config.getInputRoots(),
56012
57273
  toolTarget,
56013
57274
  global: config.getGlobal(),
56014
57275
  dryRun: config.isPreviewMode(),
@@ -56054,7 +57315,7 @@ async function generateChecksCore(params) {
56054
57315
  toolTarget,
56055
57316
  global: config.getGlobal()
56056
57317
  }),
56057
- inputRoot: config.getInputRoot(),
57318
+ inputRoots: config.getInputRoots(),
56058
57319
  toolTarget,
56059
57320
  global: config.getGlobal(),
56060
57321
  dryRun: config.isPreviewMode(),
@@ -56918,12 +58179,6 @@ Object.defineProperty(exports, "checkPathTraversal", {
56918
58179
  return checkPathTraversal;
56919
58180
  }
56920
58181
  });
56921
- Object.defineProperty(exports, "checkRulesyncDirExists", {
56922
- enumerable: true,
56923
- get: function() {
56924
- return checkRulesyncDirExists;
56925
- }
56926
- });
56927
58182
  Object.defineProperty(exports, "convertFromTool", {
56928
58183
  enumerable: true,
56929
58184
  get: function() {
@@ -57020,6 +58275,12 @@ Object.defineProperty(exports, "importFromTool", {
57020
58275
  return importFromTool;
57021
58276
  }
57022
58277
  });
58278
+ Object.defineProperty(exports, "inspectInputRoots", {
58279
+ enumerable: true,
58280
+ get: function() {
58281
+ return inspectInputRoots;
58282
+ }
58283
+ });
57023
58284
  Object.defineProperty(exports, "isPackagingToolTarget", {
57024
58285
  enumerable: true,
57025
58286
  get: function() {
@@ -57044,6 +58305,12 @@ Object.defineProperty(exports, "loadYaml", {
57044
58305
  return loadYaml;
57045
58306
  }
57046
58307
  });
58308
+ Object.defineProperty(exports, "mergeInputRootConfigs", {
58309
+ enumerable: true,
58310
+ get: function() {
58311
+ return mergeInputRootConfigs;
58312
+ }
58313
+ });
57047
58314
  Object.defineProperty(exports, "parseJsonc", {
57048
58315
  enumerable: true,
57049
58316
  get: function() {
@@ -57092,6 +58359,12 @@ Object.defineProperty(exports, "removeTempDirectory", {
57092
58359
  return removeTempDirectory;
57093
58360
  }
57094
58361
  });
58362
+ Object.defineProperty(exports, "resolveEffectiveInputRoots", {
58363
+ enumerable: true,
58364
+ get: function() {
58365
+ return resolveEffectiveInputRoots;
58366
+ }
58367
+ });
57095
58368
  Object.defineProperty(exports, "resolvePath", {
57096
58369
  enumerable: true,
57097
58370
  get: function() {