rulesync 16.14.0 → 16.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
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 }) {
4059
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
@@ -4422,11 +4834,26 @@ const KiloPermissionsOverrideSchema = zod_mini.z.looseObject({
4422
4834
  * rather than which commands are permitted — so it is a loose passthrough on
4423
4835
  * the same terms, merged into the top level of `.claude/settings.json`.
4424
4836
  *
4837
+ * Any other key is a plain top-level `settings.json` key (`editorMode`, `env`,
4838
+ * `model`, ...), deep-merged into the generated file verbatim so settings
4839
+ * Claude Code adds faster than an allowlist can track stay authorable.
4840
+ * The exceptions are the keys another feature owns (`hooks`) and `$schema`.
4841
+ * Keys the target file cannot honor — `Managed`-only and `~/.claude.json`-only
4842
+ * keys in either scope, plus user-scope keys at project scope — are dropped
4843
+ * with a warning rather than written where they would never apply. So are the
4844
+ * keys whose value is a command Claude Code executes (`apiKeyHelper`,
4845
+ * `statusLine`, ...): this file is shareable via `rulesync fetch`, and a file
4846
+ * named for restricting things is not where a command belongs — author those
4847
+ * in `.rulesync/hooks.jsonc` instead.
4848
+ *
4425
4849
  * @example
4426
4850
  * { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
4427
4851
  * @example
4428
4852
  * { "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } } }
4853
+ * @example
4854
+ * { "editorMode": "vim", "env": { "MY_VAR": "1" } }
4429
4855
  * @see https://code.claude.com/docs/en/sandboxing
4856
+ * @see https://code.claude.com/docs/en/settings-reference
4430
4857
  */
4431
4858
  const ClaudecodePermissionsOverrideSchema = zod_mini.z.looseObject({
4432
4859
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -5101,6 +5528,15 @@ const CodexcliPermissionsOverrideSchema = zod_mini.z.looseObject({
5101
5528
  sandbox_workspace_write: zod_mini.z.optional(zod_mini.z.looseObject({})),
5102
5529
  apps: zod_mini.z.optional(zod_mini.z.looseObject({})),
5103
5530
  approvals_reviewer: zod_mini.z.optional(zod_mini.z.union([CodexApprovalsReviewerSchema, zod_mini.z.looseObject({})])),
5531
+ /**
5532
+ * The `[tui]` table of `config.toml` (e.g. `vim_mode_default`, `keymap.*`).
5533
+ * Not a permission surface, but like `apps` it is a top-level table with no
5534
+ * canonical category, and Codex adds keys to it faster than an explicit model
5535
+ * could track — so it is a loose passthrough written verbatim.
5536
+ *
5537
+ * @see https://developers.openai.com/codex/config-reference
5538
+ */
5539
+ tui: zod_mini.z.optional(zod_mini.z.looseObject({})),
5104
5540
  git_write_rules: zod_mini.z.optional(zod_mini.z.boolean())
5105
5541
  });
5106
5542
  /**
@@ -5247,15 +5683,17 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
5247
5683
  error: null
5248
5684
  };
5249
5685
  }
5250
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
5686
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, validate = true }) {
5251
5687
  const paths = RulesyncPermissions.getSettablePaths();
5688
+ const overrideDirPath = relativeDirPath;
5252
5689
  for (const candidate of getRulesyncSourceCandidates({ paths })) {
5253
- 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);
5254
5692
  if (!await fileExists(filePath)) continue;
5255
5693
  const fileContent = await readFileContent(filePath);
5256
5694
  return new RulesyncPermissions({
5257
5695
  outputRoot,
5258
- relativeDirPath: candidate.relativeDirPath,
5696
+ relativeDirPath: candidateDirPath,
5259
5697
  relativeFilePath: candidate.relativeFilePath,
5260
5698
  fileContent,
5261
5699
  validate
@@ -5419,8 +5857,9 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5419
5857
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5420
5858
  };
5421
5859
  }
5422
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true }) {
5423
- 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);
5424
5863
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5425
5864
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5426
5865
  const result = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
@@ -5433,7 +5872,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5433
5872
  };
5434
5873
  return new RulesyncRule({
5435
5874
  outputRoot,
5436
- relativeDirPath: this.getSettablePaths().recommended.relativeDirPath,
5875
+ relativeDirPath: dirPath,
5437
5876
  relativeFilePath,
5438
5877
  frontmatter: validatedFrontmatter,
5439
5878
  body: content.trim(),
@@ -5681,7 +6120,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5681
6120
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5682
6121
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5683
6122
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
5684
- "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())
5685
6128
  })),
5686
6129
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
5687
6130
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -5857,8 +6300,9 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5857
6300
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
5858
6301
  };
5859
6302
  }
5860
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath }) {
5861
- 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);
5862
6306
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
5863
6307
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${filePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
5864
6308
  const result = RulesyncSubagentFrontmatterSchema.safeParse(frontmatter);
@@ -5866,7 +6310,7 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5866
6310
  const filename = (0, node_path.basename)(relativeFilePath);
5867
6311
  return new RulesyncSubagent({
5868
6312
  outputRoot,
5869
- relativeDirPath: this.getSettablePaths().relativeDirPath,
6313
+ relativeDirPath: dirPath,
5870
6314
  relativeFilePath: filename,
5871
6315
  frontmatter: result.data,
5872
6316
  body: content.trim()
@@ -5876,16 +6320,18 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
5876
6320
  //#endregion
5877
6321
  //#region src/features/skills/skills-utils.ts
5878
6322
  /**
5879
- * 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`).
5880
6326
  */
5881
- async function getLocalSkillDirNames(outputRoot) {
5882
- 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);
5883
6329
  const names = /* @__PURE__ */ new Set();
5884
6330
  if (!await directoryExists(skillsDir)) return names;
5885
6331
  const dirPaths = await findFilesByGlobs((0, node_path.join)(skillsDir, "*"), { type: "dir" });
5886
6332
  for (const dirPath of dirPaths) {
5887
6333
  const name = (0, node_path.basename)(dirPath);
5888
- 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;
5889
6335
  names.add(name);
5890
6336
  }
5891
6337
  return names;
@@ -6198,15 +6644,49 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6198
6644
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6199
6645
  }
6200
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
6201
6668
  //#region src/types/feature-processor.ts
6202
6669
  var FeatureProcessor = class {
6203
6670
  outputRoot;
6204
- 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;
6205
6685
  dryRun;
6206
6686
  logger;
6207
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, logger }) {
6687
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, logger }) {
6208
6688
  this.outputRoot = outputRoot;
6209
- 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)];
6210
6690
  this.dryRun = dryRun;
6211
6691
  this.logger = logger;
6212
6692
  }
@@ -6273,6 +6753,198 @@ var FeatureProcessor = class {
6273
6753
  return orphanFiles.length;
6274
6754
  }
6275
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
+ };
6276
6948
  //#endregion
6277
6949
  //#region src/constants/amp-paths.ts
6278
6950
  const AMP_DIR = ".amp";
@@ -7488,7 +8160,8 @@ const CODEXCLI_OVERRIDE_KEYS = [
7488
8160
  "sandbox_mode",
7489
8161
  "sandbox_workspace_write",
7490
8162
  "apps",
7491
- "approvals_reviewer"
8163
+ "approvals_reviewer",
8164
+ "tui"
7492
8165
  ];
7493
8166
  //#endregion
7494
8167
  //#region src/features/shared/shared-config-gateway.ts
@@ -8594,10 +9267,10 @@ var ChecksProcessor = class extends FeatureProcessor {
8594
9267
  toolTarget;
8595
9268
  global;
8596
9269
  getFactory;
8597
- 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 }) {
8598
9271
  super({
8599
9272
  outputRoot,
8600
- inputRoot,
9273
+ inputRoots,
8601
9274
  dryRun,
8602
9275
  logger
8603
9276
  });
@@ -8634,11 +9307,15 @@ var ChecksProcessor = class extends FeatureProcessor {
8634
9307
  return toolFiles.filter((file) => file instanceof ToolCheck).flatMap((toolCheck) => toolCheck.toRulesyncChecks());
8635
9308
  }
8636
9309
  /**
8637
- * Implementation of abstract method from Processor
8638
- * 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`).
8639
9313
  */
8640
- async loadRulesyncFiles() {
8641
- 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);
8642
9319
  if (!await directoryExists(checksDir)) {
8643
9320
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
8644
9321
  return [];
@@ -8654,7 +9331,8 @@ var ChecksProcessor = class extends FeatureProcessor {
8654
9331
  const filepath = (0, node_path.join)(checksDir, mdFile);
8655
9332
  try {
8656
9333
  const rulesyncCheck = await RulesyncCheck.fromFile({
8657
- outputRoot: this.inputRoot,
9334
+ outputRoot: treeParent,
9335
+ relativeDirPath: treeChecksDirPath,
8658
9336
  relativeFilePath: mdFile,
8659
9337
  validate: true
8660
9338
  });
@@ -8665,6 +9343,21 @@ var ChecksProcessor = class extends FeatureProcessor {
8665
9343
  continue;
8666
9344
  }
8667
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
+ });
8668
9361
  this.logger.debug(`Successfully loaded ${rulesyncChecks.length} rulesync checks`);
8669
9362
  return rulesyncChecks;
8670
9363
  }
@@ -10143,18 +10836,23 @@ function commandSlug(relativeFilePath) {
10143
10836
  return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-");
10144
10837
  }
10145
10838
  /**
10146
- * 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/`.
10147
10845
  *
10148
10846
  * Used by the skills-surface `isDirOwned` hooks of tools whose commands are
10149
10847
  * emitted as `<slug>/SKILL.md` into the skills tree: a directory matching a
10150
10848
  * current command slug is owned by the commands feature, so the skills
10151
10849
  * feature must neither import it as a skill nor delete it as an orphan
10152
- * skill. Once the command is removed from `.rulesync/commands/`, the
10153
- * directory stops matching and the skills feature cleans it up as a regular
10154
- * orphan.
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.
10155
10853
  */
10156
- async function rulesyncCommandSlugExists({ inputRoot, dirName }) {
10157
- 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);
10158
10856
  }
10159
10857
  //#endregion
10160
10858
  //#region src/features/commands/devin-command.ts
@@ -10844,11 +11542,10 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
10844
11542
  * output is lost — so this warns rather than failing the run the way the
10845
11543
  * Hermes check does, where the two surfaces really do write the same path.
10846
11544
  */
10847
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands, logger }) {
11545
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands, logger }) {
10848
11546
  const commandNames = new Set(rulesyncCommands.filter((command) => this.isTargetedByRulesyncCommand(command)).map((command) => (0, node_path.basename)(command.getRelativeFilePath(), ".md")));
10849
11547
  if (commandNames.size === 0) return;
10850
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
10851
- 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));
10852
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.`);
10853
11550
  }
10854
11551
  static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
@@ -11620,7 +12317,7 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11620
12317
  static getExtraSharedWritePaths() {
11621
12318
  return getHermesagentSharedConfigWritePaths();
11622
12319
  }
11623
- static async validateRulesyncCommands({ inputRoot, rulesyncCommands }) {
12320
+ static async validateRulesyncCommands({ inputRoots, rulesyncCommands }) {
11624
12321
  const commandSlugs = /* @__PURE__ */ new Set();
11625
12322
  const commandOrigins = /* @__PURE__ */ new Map();
11626
12323
  for (const command of rulesyncCommands.filter((candidate) => this.isTargetedByRulesyncCommand(candidate))) {
@@ -11632,13 +12329,25 @@ var HermesagentCommand = class HermesagentCommand extends ToolCommand {
11632
12329
  commandOrigins.set(slug, origin);
11633
12330
  commandSlugs.add(slug);
11634
12331
  }
11635
- const skillsRoot = (0, node_path.join)(inputRoot, RULESYNC_SKILLS_RELATIVE_DIR_PATH);
11636
- const skillFiles = await findFilesByGlobs((0, node_path.join)(skillsRoot, "**", "SKILL.md"));
11637
- const collisions = (await Promise.all(skillFiles.map((path) => RulesyncSkill.fromDir({
11638
- outputRoot: inputRoot,
11639
- relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
11640
- dirName: toPosixPath((0, node_path.relative)(skillsRoot, (0, node_path.dirname)(path)))
11641
- })))).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));
11642
12351
  if (collisions.length > 0) throw new Error(`Hermes command and skill slash-name collision: ${[...new Set(collisions)].toSorted().join(", ")}`);
11643
12352
  }
11644
12353
  static async getAuxiliaryFiles({ toolCommands, outputRoot, global = false, forDeletion = false }) {
@@ -11771,6 +12480,8 @@ const JUNIE_PERMISSIONS_FILE_NAME = "allowlist.json";
11771
12480
  const JUNIE_IGNORE_FILE_NAME = ".aiignore";
11772
12481
  const JUNIE_RULE_FILE_NAME = "AGENTS.md";
11773
12482
  const JUNIE_LEGACY_RULE_FILE_NAME = "guidelines.md";
12483
+ const JUNIE_RULES_DIR_NAME = "rules";
12484
+ const JUNIE_PLAYBOOK_FILE_NAME = "playbook.md";
11774
12485
  //#endregion
11775
12486
  //#region src/features/commands/junie-command.ts
11776
12487
  const JunieCommandFrontmatterSchema = zod_mini.z.looseObject({ description: zod_mini.z.optional(zod_mini.z.string()) });
@@ -13731,10 +14442,10 @@ var CommandsProcessor = class extends FeatureProcessor {
13731
14442
  global;
13732
14443
  getFactory;
13733
14444
  flattenedCommandNaming;
13734
- 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 }) {
13735
14446
  super({
13736
14447
  outputRoot,
13737
- inputRoot,
14448
+ inputRoots,
13738
14449
  dryRun,
13739
14450
  logger
13740
14451
  });
@@ -13749,7 +14460,7 @@ var CommandsProcessor = class extends FeatureProcessor {
13749
14460
  const rulesyncCommands = rulesyncFiles.filter((file) => file instanceof RulesyncCommand);
13750
14461
  const factory = this.getFactory(this.toolTarget);
13751
14462
  await factory.class.validateRulesyncCommands?.({
13752
- inputRoot: this.inputRoot,
14463
+ inputRoots: this.inputRoots,
13753
14464
  rulesyncCommands,
13754
14465
  logger: this.logger
13755
14466
  });
@@ -13801,16 +14512,36 @@ var CommandsProcessor = class extends FeatureProcessor {
13801
14512
  return rel;
13802
14513
  }
13803
14514
  /**
13804
- * Implementation of abstract method from FeatureProcessor
13805
- * 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`).
13806
14518
  */
13807
- async loadRulesyncFiles() {
13808
- 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);
13809
14524
  const rulesyncCommandPaths = await findFilesByGlobs((0, node_path.join)(basePath, "**", "*.md"));
13810
- const rulesyncCommands = await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
13811
- outputRoot: this.inputRoot,
14525
+ return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
14526
+ outputRoot: treeParent,
14527
+ relativeDirPath: treeCommandsDirPath,
13812
14528
  relativeFilePath: this.safeRelativePath(basePath, path)
13813
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
+ });
13814
14545
  this.logger.debug(`Successfully loaded ${rulesyncCommands.length} rulesync commands`);
13815
14546
  return rulesyncCommands;
13816
14547
  }
@@ -19450,6 +20181,7 @@ function vibeEntryToCanonicalDef(raw) {
19450
20181
  const entry = raw;
19451
20182
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
19452
20183
  if (vibeEvent === void 0) return null;
20184
+ if (isPrototypePollutionKey(vibeEvent)) return null;
19453
20185
  const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
19454
20186
  const def = { type: "command" };
19455
20187
  if (typeof entry.command === "string") def.command = entry.command;
@@ -19998,10 +20730,10 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
19998
20730
  var HooksProcessor = class extends FeatureProcessor {
19999
20731
  toolTarget;
20000
20732
  global;
20001
- 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 }) {
20002
20734
  super({
20003
20735
  outputRoot,
20004
- inputRoot,
20736
+ inputRoots,
20005
20737
  dryRun,
20006
20738
  logger
20007
20739
  });
@@ -20011,9 +20743,17 @@ var HooksProcessor = class extends FeatureProcessor {
20011
20743
  this.global = global;
20012
20744
  }
20013
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];
20014
20753
  try {
20015
20754
  return [await RulesyncHooks.fromFile({
20016
- outputRoot: this.inputRoot,
20755
+ outputRoot: (0, node_path.dirname)(sourceTree),
20756
+ relativeDirPath: (0, node_path.basename)(sourceTree),
20017
20757
  validate: true
20018
20758
  })];
20019
20759
  } catch (error) {
@@ -21555,10 +22295,10 @@ var IgnoreProcessor = class extends FeatureProcessor {
21555
22295
  getFactory;
21556
22296
  featureOptions;
21557
22297
  global;
21558
- 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 }) {
21559
22299
  super({
21560
22300
  outputRoot,
21561
- inputRoot,
22301
+ inputRoots,
21562
22302
  dryRun,
21563
22303
  logger
21564
22304
  });
@@ -21575,11 +22315,35 @@ var IgnoreProcessor = class extends FeatureProcessor {
21575
22315
  }
21576
22316
  /**
21577
22317
  * Implementation of abstract method from FeatureProcessor
21578
- * 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.
21579
22332
  */
21580
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];
21581
22342
  try {
21582
- 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
+ })];
21583
22347
  } catch (error) {
21584
22348
  this.logger.error(`Failed to load rulesync ignore file (${RULESYNC_AIIGNORE_RELATIVE_FILE_PATH}): ${formatError(error)}`);
21585
22349
  return [];
@@ -26614,11 +27378,86 @@ function disabledNamesOf(config) {
26614
27378
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
26615
27379
  }
26616
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
+ /**
26617
27453
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
26618
27454
  * `~/.rovodev/config.yml` (global). Carries `disabledMcpServers` — the key
26619
- * Rovo Dev actually consults to switch a server off — recomputed from the
26620
- * existing block so user keys (`mcpConfigPath`, `allowedMcpServers`, ...) and
26621
- * disabled names for servers rulesync does not manage survive.
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.
26622
27461
  */
26623
27462
  var RovodevMcpConfigYaml = class extends ToolFile {
26624
27463
  isDeletable() {
@@ -26751,7 +27590,17 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
26751
27590
  const mergedDisabled = [...existingDisabled.filter((name) => !managedNameSet.has(name)), ...disabledNames].toSorted();
26752
27591
  if (mergedDisabled.length > 0) existingMcp.disabledMcpServers = mergedDisabled;
26753
27592
  else delete existingMcp.disabledMcpServers;
26754
- 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 [];
26755
27604
  const fileContent = applySharedConfigPatch({
26756
27605
  fileKey: ROVODEV_CONFIG_SHARED_FILE_KEY,
26757
27606
  feature: "mcp",
@@ -27844,10 +28693,10 @@ var McpProcessor = class extends FeatureProcessor {
27844
28693
  toolTarget;
27845
28694
  global;
27846
28695
  getFactory;
27847
- 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 }) {
27848
28697
  super({
27849
28698
  outputRoot,
27850
- inputRoot,
28699
+ inputRoots,
27851
28700
  dryRun,
27852
28701
  logger
27853
28702
  });
@@ -27863,7 +28712,10 @@ var McpProcessor = class extends FeatureProcessor {
27863
28712
  */
27864
28713
  async loadRulesyncFiles() {
27865
28714
  try {
27866
- return [await RulesyncMcp.fromFile({ outputRoot: this.inputRoot })];
28715
+ return [await RulesyncMcp.fromRoots({
28716
+ inputRoots: this.inputRoots,
28717
+ logger: this.logger
28718
+ })];
27867
28719
  } catch (error) {
27868
28720
  this.logger.error(`Failed to load a Rulesync MCP file (${RULESYNC_MCP_RELATIVE_FILE_PATH}): ${formatError(error)}`);
27869
28721
  return [];
@@ -29489,8 +30341,9 @@ function deepMergeRecords(base, patch) {
29489
30341
  * emits them only under `--global`.
29490
30342
  *
29491
30343
  * Deliberately NOT listed:
29492
- * - `bwrapPath` / `socatPath`: v2.1.232 added them to the managed-settings
29493
- * approval dialog, which is a consent prompt, not a project-scope rejection.
30344
+ * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
30345
+ * `CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL` refuses them in both scopes
30346
+ * rather than emitting them under `--global`.
29494
30347
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29495
30348
  * unit is the individual entry's mode, not the settings key, and the same
29496
30349
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29498,7 +30351,6 @@ function deepMergeRecords(base, patch) {
29498
30351
  * filters those lists per entry instead.
29499
30352
  *
29500
30353
  * @see https://code.claude.com/docs/en/sandboxing
29501
- * @see https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — v2.1.232 scoped `sandbox.ripgrep`
29502
30354
  */
29503
30355
  const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29504
30356
  ["filesystem", "disabled"],
@@ -29507,37 +30359,309 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29507
30359
  ["credentials", "allowPlaintextInject"],
29508
30360
  ["credentials", "awsPairs"],
29509
30361
  ["credentials", "sigv4"],
29510
- ["allowAppleEvents"],
29511
- ["ripgrep"]
30362
+ ["allowAppleEvents"]
30363
+ ];
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
+ /**
30389
+ * Walks `segments` from `root`, returning the record they name or `undefined` if
30390
+ * any step is missing or not a record. Shared by everything below that addresses
30391
+ * a `sandbox` path, so a nested path added to one of the tables is actually
30392
+ * traversed rather than silently skipped.
30393
+ */
30394
+ function resolveSandboxParent({ root, segments }) {
30395
+ let parent = root;
30396
+ for (const segment of segments) {
30397
+ const next = parent[segment];
30398
+ if (!isPlainRecord(next)) return void 0;
30399
+ parent = next;
30400
+ }
30401
+ return parent;
30402
+ }
30403
+ /**
30404
+ * Deletes `path` from `target` in place and reports whether anything was there,
30405
+ * dropping a container the removal emptied so no `"network": {}` noise is left
30406
+ * behind.
30407
+ */
30408
+ function deleteSandboxPath({ target, path }) {
30409
+ const leaf = path.at(-1);
30410
+ if (leaf === void 0) return false;
30411
+ const parentPath = path.slice(0, -1);
30412
+ const parent = resolveSandboxParent({
30413
+ root: target,
30414
+ segments: parentPath
30415
+ });
30416
+ if (parent === void 0 || parent[leaf] === void 0) return false;
30417
+ delete parent[leaf];
30418
+ for (let depth = parentPath.length; depth > 0; depth--) {
30419
+ const container = resolveSandboxParent({
30420
+ root: target,
30421
+ segments: parentPath.slice(0, depth)
30422
+ });
30423
+ if (container === void 0 || Object.keys(container).length > 0) break;
30424
+ const holder = resolveSandboxParent({
30425
+ root: target,
30426
+ segments: parentPath.slice(0, depth - 1)
30427
+ });
30428
+ const name = parentPath[depth - 1];
30429
+ if (holder === void 0 || name === void 0) break;
30430
+ delete holder[name];
30431
+ }
30432
+ return true;
30433
+ }
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
+ /**
30447
+ * The `permissions.defaultMode` values that start a session with fewer prompts
30448
+ * than the default. `plan` and `default` are absent because they do not widen
30449
+ * anything.
30450
+ */
30451
+ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
30452
+ acceptEdits: "every file edit is then applied without a prompt",
30453
+ auto: "shell commands are then auto-approved by a classifier rather than by you",
30454
+ bypassPermissions: "every session then starts with no permission prompts at all"
30455
+ };
30456
+ /**
30457
+ * The `permissions` fields that widen rather than restrict: a `defaultMode` that
30458
+ * removes prompts, and `additionalDirectories`, which moves the
30459
+ * working-directory boundary. Reported for the same reason `disableAllHooks` is:
30460
+ * a shareable permissions file should not loosen the permission system quietly.
30461
+ */
30462
+ function collectWideningPermissionFields({ fields }) {
30463
+ const entries = [];
30464
+ const defaultMode = fields.defaultMode;
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
+ });
30469
+ const additionalDirectories = fields.additionalDirectories;
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;
30475
+ }
30476
+ /**
30477
+ * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
30478
+ * own merge branch, so the top-level refusal in `stripUnhonoredTopLevelKeys`
30479
+ * never sees them — they are refused here on the same grounds, in both scopes:
30480
+ * a fetched `.rulesync/permissions.jsonc` must not be able to point Claude Code
30481
+ * at an executable of its choosing.
30482
+ *
30483
+ * @see https://code.claude.com/docs/en/sandboxing
30484
+ */
30485
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
30486
+ ["ripgrep"],
30487
+ ["bwrapPath"],
30488
+ ["socatPath"]
29512
30489
  ];
29513
30490
  /**
29514
- * Copy of the authored `sandbox` override with the user/managed-only paths
29515
- * removed, warning once per dropped path. Only the override copy is filtered
29516
- * a value already hand-written in the target file is left untouched, matching
29517
- * the `qwencode` `security.allowPrivateNetworkHooks` precedent.
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
+ /**
30503
+ * `sandbox` paths that loosen the sandbox rather than naming something to run:
30504
+ * they let commands out of it, weaken the isolation it provides, or redirect
30505
+ * where its traffic goes. They are written like `env` is — the ordinary uses are
30506
+ * too common to refuse — but never silently, because a fetched override should
30507
+ * not be able to open the sandbox without saying so. `widens` keeps the warning
30508
+ * to the value that actually loosens the policy, so authoring the restrictive
30509
+ * value (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays
30510
+ * quiet. The `allow*` lists are here for a structural reason: Claude Code merges
30511
+ * a list across every settings scope rather than replacing it, so a project file
30512
+ * can only ever add to them. Their counterparts — `denyRead`, `denyWrite`,
30513
+ * `deniedDomains` — merge the same way, but adding to a deny list only ever
30514
+ * narrows the policy, so they are absent.
30515
+ *
30516
+ * @see https://code.claude.com/docs/en/sandboxing
29518
30517
  */
29519
- function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
29520
- const filtered = structuredClone(sandbox);
29521
- for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
30518
+ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
30519
+ {
30520
+ path: ["allowAppleEvents"],
30521
+ reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
30522
+ widens: isNotFalse
30523
+ },
30524
+ {
30525
+ path: ["allowUnsandboxedCommands"],
30526
+ reason: "controls whether Claude may retry a blocked command outside the sandbox",
30527
+ widens: isNotFalse
30528
+ },
30529
+ {
30530
+ path: ["autoAllowBashIfSandboxed"],
30531
+ reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
30532
+ widens: isNotFalse
30533
+ },
30534
+ {
30535
+ path: ["enableWeakerNestedSandbox"],
30536
+ reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
30537
+ widens: isNotFalse
30538
+ },
30539
+ {
30540
+ path: ["enableWeakerNetworkIsolation"],
30541
+ reason: "weakens the sandbox's network isolation on macOS",
30542
+ widens: isNotFalse
30543
+ },
30544
+ {
30545
+ path: ["enabled"],
30546
+ reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
30547
+ widens: isNotFalse
30548
+ },
30549
+ {
30550
+ path: ["excludedCommands"],
30551
+ reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
30552
+ widens: isNonEmptyList
30553
+ },
30554
+ {
30555
+ path: ["filesystem", "allowRead"],
30556
+ reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
30557
+ widens: isNonEmptyList
30558
+ },
30559
+ {
30560
+ path: ["filesystem", "allowWrite"],
30561
+ reason: "adds paths sandboxed commands may write to, outside the working directory",
30562
+ widens: isNonEmptyList
30563
+ },
30564
+ {
30565
+ path: ["ignoreViolations"],
30566
+ reason: "hides the sandbox violations it names, so a blocked access stops being reported",
30567
+ widens: (value) => isNonEmptyMap(value) && isNotFalse(value)
30568
+ },
30569
+ {
30570
+ path: ["network", "allowAllUnixSockets"],
30571
+ reason: "lets sandboxed commands connect to every Unix socket",
30572
+ widens: isNotFalse
30573
+ },
30574
+ {
30575
+ path: ["network", "allowedDomains"],
30576
+ reason: "pre-allows domains sandboxed commands may reach without a prompt",
30577
+ widens: isNonEmptyList
30578
+ },
30579
+ {
30580
+ path: ["network", "allowLocalBinding"],
30581
+ reason: "lets sandboxed commands bind local ports",
30582
+ widens: isNotFalse
30583
+ },
30584
+ {
30585
+ path: ["network", "allowMachLookup"],
30586
+ reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
30587
+ widens: isNonEmptyList
30588
+ },
30589
+ {
30590
+ path: ["network", "allowUnixSockets"],
30591
+ reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
30592
+ widens: isNonEmptyList
30593
+ },
30594
+ {
30595
+ path: ["network", "httpProxyPort"],
30596
+ reason: "routes the sandbox's HTTP traffic through the port it names",
30597
+ widens: () => true
30598
+ },
30599
+ {
30600
+ path: ["network", "socksProxyPort"],
30601
+ reason: "routes the sandbox's SOCKS traffic through the port it names",
30602
+ widens: () => true
30603
+ }
30604
+ ];
30605
+ /**
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.
30609
+ */
30610
+ function collectTrustAffectingSandboxPaths({ sandbox }) {
30611
+ const entries = [];
30612
+ for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29522
30613
  const leaf = path.at(-1);
29523
30614
  if (leaf === void 0) continue;
29524
- const parentPath = path.slice(0, -1);
29525
- let parent = filtered;
29526
- for (const segment of parentPath) {
29527
- const next = parent[segment];
29528
- if (!isPlainRecord(next)) {
29529
- parent = {};
29530
- break;
29531
- }
29532
- parent = next;
29533
- }
29534
- if (parent[leaf] === void 0) continue;
29535
- delete parent[leaf];
29536
- const [container] = parentPath;
29537
- if (container !== void 0 && isPlainRecord(filtered[container])) {
29538
- if (Object.keys(filtered[container]).length === 0) delete filtered[container];
29539
- }
29540
- 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.`);
30615
+ const parent = resolveSandboxParent({
30616
+ root: sandbox,
30617
+ segments: path.slice(0, -1)
30618
+ });
30619
+ if (parent === void 0) continue;
30620
+ const value = parent[leaf];
30621
+ if (value === void 0 || !widens(value)) continue;
30622
+ entries.push({
30623
+ label: `sandbox.${path.join(".")}`,
30624
+ reason
30625
+ });
30626
+ }
30627
+ return entries;
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
+ };
30634
+ /**
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.
30638
+ */
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
+ };
30648
+ /**
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.
30653
+ */
30654
+ function stripSandboxPaths({ sandbox, refusals, relativeFilePath, logger }) {
30655
+ const filtered = structuredClone(sandbox);
30656
+ for (const { paths, warn } of refusals) for (const path of paths) {
30657
+ if (!deleteSandboxPath({
30658
+ target: filtered,
30659
+ path
30660
+ })) continue;
30661
+ logger?.warn(warn({
30662
+ label: `sandbox.${path.join(".")}`,
30663
+ relativeFilePath
30664
+ }));
29541
30665
  }
29542
30666
  return filtered;
29543
30667
  }
@@ -29561,7 +30685,7 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
29561
30685
  * such entries to `deny`), so the "reads as masked but isn't" state this guards
29562
30686
  * against cannot slip through a differently-spelled value.
29563
30687
  *
29564
- * Like `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a
30688
+ * Like `stripSandboxPaths`, only the override copy is filtered — a
29565
30689
  * value already in the target file is left untouched, which is why the warning
29566
30690
  * points at it.
29567
30691
  *
@@ -29592,6 +30716,271 @@ function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
29592
30716
  else filtered.credentials = filteredCredentials;
29593
30717
  return filtered;
29594
30718
  }
30719
+ /**
30720
+ * Top-level `.claude/settings.json` keys another feature owns, derived from
30721
+ * {@link SHARED_CONFIG_OWNERSHIP} rather than restated here so a feature that
30722
+ * starts owning a new key is excluded from the passthrough automatically
30723
+ * (today: `hooks`, from the hooks feature). Only `replace-owned-keys` entries
30724
+ * name keys; the `custom` policies on this file (`ignore`, `permissions`) own
30725
+ * entries *inside* `permissions`, which the passthrough excludes wholesale.
30726
+ */
30727
+ const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.features ?? {}).flatMap(([feature, policy]) => feature !== "permissions" && policy.kind === "replace-owned-keys" ? policy.ownedKeys : []);
30728
+ /**
30729
+ * Top-level `.claude/settings.json` keys the generic `claudecode` override
30730
+ * passthrough must not carry. `permissions` and `sandbox` have their own merge
30731
+ * branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
30732
+ * respectively), `permission` is rulesync's own canonical tool-scoped block
30733
+ * rather than a settings key, `$schema` is an editor pointer rather than a
30734
+ * Claude Code setting, and the rest belong to the other features writing this
30735
+ * shared file.
30736
+ */
30737
+ const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
30738
+ "permission",
30739
+ "permissions",
30740
+ "sandbox",
30741
+ "$schema",
30742
+ ...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
30743
+ ]);
30744
+ /**
30745
+ * Top-level settings keys Claude Code reads only from user settings, managed
30746
+ * settings and the `--settings` CLI flag — the same restriction
30747
+ * `CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS` records for the `sandbox` subtree, and
30748
+ * the reason the passthrough drops them at project scope instead of committing
30749
+ * a setting that never applies. `rulesync generate --global` writes the user
30750
+ * settings file, so they are emitted there.
30751
+ *
30752
+ * Derived from the per-key **Scope** column of the settings reference: every
30753
+ * top-level key documented as `User or managed` or `User, local, or managed`.
30754
+ *
30755
+ * @see https://code.claude.com/docs/en/settings-reference
30756
+ */
30757
+ const CLAUDECODE_USER_SCOPE_ONLY_KEYS = /* @__PURE__ */ new Set([
30758
+ "askUserQuestionTimeout",
30759
+ "autoMode",
30760
+ "dialogExpiry",
30761
+ "enableArtifact",
30762
+ "footerLinksRegexes",
30763
+ "pluginConfigs",
30764
+ "skipAutoPermissionPrompt",
30765
+ "skipDangerousModePermissionPrompt",
30766
+ "spellcheck",
30767
+ "sshConfigs",
30768
+ "syncClaudeAiSkills",
30769
+ "useAutoModeDuringPlan",
30770
+ "vimInsertModeRemaps"
30771
+ ]);
30772
+ /**
30773
+ * Top-level settings keys neither file rulesync writes can honor, with the file
30774
+ * that does. `Managed` keys are read only from the settings file an organization
30775
+ * deploys, and `Global config` keys only from `~/.claude.json` — rulesync writes
30776
+ * `.claude/settings.json` and `~/.claude/settings.json`, so authoring either
30777
+ * kind through the override would produce a policy that silently never applies.
30778
+ *
30779
+ * Derived from the per-key **Scope** column of the settings reference.
30780
+ *
30781
+ * @see https://code.claude.com/docs/en/settings-reference
30782
+ */
30783
+ const CLAUDECODE_UNHONORED_KEY_SOURCES = {
30784
+ allowAllClaudeAiMcps: "managed settings",
30785
+ allowedChannelPlugins: "managed settings",
30786
+ allowManagedHooksOnly: "managed settings",
30787
+ allowManagedMcpServersOnly: "managed settings",
30788
+ allowManagedPermissionRulesOnly: "managed settings",
30789
+ autoConnectIde: "~/.claude.json",
30790
+ autoInstallIdeExtension: "~/.claude.json",
30791
+ blockedMarketplaces: "managed settings",
30792
+ browserExternalPageTools: "managed settings",
30793
+ channelsEnabled: "managed settings",
30794
+ claudeMd: "managed settings",
30795
+ diffTool: "~/.claude.json",
30796
+ disableBrowserExternalNavigation: "managed settings",
30797
+ disableCommandPluginSources: "managed settings",
30798
+ disableMobileSimulatorTools: "managed settings",
30799
+ disableSideloadFlags: "managed settings",
30800
+ externalEditorContext: "~/.claude.json",
30801
+ forceLoginGatewayUrl: "managed settings",
30802
+ forceRemoteSettingsRefresh: "managed settings",
30803
+ parentSettingsBehavior: "managed settings",
30804
+ permissionExplainerEnabled: "~/.claude.json",
30805
+ pluginSuggestionMarketplaces: "managed settings",
30806
+ pluginTrustMessage: "managed settings",
30807
+ requiredMaximumVersion: "managed settings",
30808
+ requiredMinimumVersion: "managed settings",
30809
+ sshHostAllowlist: "managed settings",
30810
+ strictKnownMarketplaces: "managed settings",
30811
+ strictPluginOnlyCustomization: "managed settings",
30812
+ teammateDefaultModel: "~/.claude.json",
30813
+ wslInheritsWindowsSettings: "managed settings"
30814
+ };
30815
+ /**
30816
+ * Top-level settings keys whose value Claude Code **executes**. The generic
30817
+ * passthrough refuses them outright rather than warning: `.rulesync/*` files
30818
+ * are shareable — `rulesync fetch` copies a third party's `permissions.jsonc`
30819
+ * straight into a project — and a file named for *restricting* what an agent
30820
+ * may do is not somewhere a reviewer looks for a command to run. Commands
30821
+ * belong in `.rulesync/hooks.jsonc` and `.rulesync/.mcp.json`, which are read
30822
+ * as executable by anyone reviewing them. Set these by hand in the settings
30823
+ * file if you need them.
30824
+ *
30825
+ * The value is the reason, spliced into the warning.
30826
+ *
30827
+ * @see https://code.claude.com/docs/en/settings-reference
30828
+ */
30829
+ const CLAUDECODE_COMMAND_EXECUTING_KEYS = {
30830
+ apiKeyHelper: "runs the script it names to mint an API key",
30831
+ awsAuthRefresh: "runs the command it names to refresh AWS credentials",
30832
+ awsCredentialExport: "runs the command it names to export AWS credentials",
30833
+ fileSuggestion: "runs its `command` on every `@` file completion",
30834
+ gcpAuthRefresh: "runs the command it names to refresh Google Cloud credentials",
30835
+ otelHeadersHelper: "runs the script it names to build OpenTelemetry headers",
30836
+ policyHelper: "runs the executable it names to compute the managed settings",
30837
+ processWrapper: "wraps every process Claude Code spawns",
30838
+ statusLine: "runs its `command` on every status-line render",
30839
+ subagentStatusLine: "runs its `command` on every subagent status row"
30840
+ };
30841
+ /**
30842
+ * Top-level settings keys the passthrough does write, but never silently: each
30843
+ * one widens what Claude Code trusts or where it sends data, so a value that
30844
+ * arrived with a fetched `.rulesync/permissions.jsonc` should be looked at
30845
+ * deliberately. Warning on write follows the precedent set for Warp's
30846
+ * `command_denylist`, which also replaces a protection when rulesync writes it.
30847
+ *
30848
+ * The value is the reason, spliced into the warning.
30849
+ */
30850
+ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
30851
+ agent: "starts every session as the named subagent, with that subagent's prompt, tools and model",
30852
+ allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
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",
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",
30858
+ disableAllHooks: "controls whether hooks run at all",
30859
+ disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
30860
+ enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
30861
+ enabledMcpjsonServers: "auto-approves the named servers in the project `.mcp.json`",
30862
+ enabledPlugins: "enables plugins, which can ship their own hooks",
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",
30864
+ extraKnownMarketplaces: "registers plugin marketplace sources",
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",
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",
30870
+ skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode 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"
30873
+ };
30874
+ /**
30875
+ * The keys from the table above that only widen at one particular value.
30876
+ * `disableSkillShellExecution: true` turns inline shell execution off, which
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
+ } };
30918
+ /**
30919
+ * A key name is authored data that ends up in a log line, so strip the control
30920
+ * characters that would let it forge a line or hide the warnings beside it, and
30921
+ * cap the length.
30922
+ */
30923
+ function displayKey(key) {
30924
+ const stripped = stripControlCharacters(key);
30925
+ return stripped.length > 80 ? `${stripped.slice(0, 80)}…` : stripped;
30926
+ }
30927
+ /**
30928
+ * Alternate spellings Claude Code accepts for a top-level settings key, mapped
30929
+ * to the canonical key whose **Scope** the alias inherits: "In any settings
30930
+ * file that accepts the canonical key, Claude Code reads the alias exactly as
30931
+ * it reads the canonical key." Resolving through this map before the scope
30932
+ * check keeps an alias from slipping past a restriction its canonical spelling
30933
+ * is caught by — `allowedMarketplaces` is `Managed`, like
30934
+ * `strictKnownMarketplaces`. Both aliases require Claude Code v2.1.232+.
30935
+ *
30936
+ * @see https://code.claude.com/docs/en/settings-reference#marketplace-key-aliases
30937
+ */
30938
+ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
30939
+ additionalMarketplaces: "extraKnownMarketplaces",
30940
+ allowedMarketplaces: "strictKnownMarketplaces"
30941
+ };
30942
+ /**
30943
+ * Copy of the authored top-level passthrough with the keys the target file
30944
+ * cannot honor removed, warning once per dropped key. Like
30945
+ * `stripSandboxPaths`, only the override copy is filtered — a value
30946
+ * already hand-written in the target file is left untouched, which is why the
30947
+ * warning points at it.
30948
+ */
30949
+ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30950
+ const filtered = {};
30951
+ const trustAffecting = [];
30952
+ for (const [key, value] of Object.entries(overrides)) {
30953
+ const shown = displayKey(key);
30954
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
30955
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) {
30956
+ logger?.warn(`Claude Code permissions: '${shown}' ${CLAUDECODE_COMMAND_EXECUTING_KEYS[canonicalKey]}, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; author commands in .rulesync/hooks.jsonc, or set this key in ${relativeFilePath} by hand.`);
30957
+ continue;
30958
+ }
30959
+ if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) {
30960
+ logger?.warn(`Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`);
30961
+ continue;
30962
+ }
30963
+ if (!global && CLAUDECODE_USER_SCOPE_ONLY_KEYS.has(canonicalKey)) {
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.`);
30965
+ continue;
30966
+ }
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
+ });
30977
+ filtered[key] = value;
30978
+ }
30979
+ return {
30980
+ filtered,
30981
+ trustAffecting
30982
+ };
30983
+ }
29595
30984
  const CLAUDE_PATH_RULE_ALIASES = {
29596
30985
  Write: "Edit",
29597
30986
  NotebookEdit: "Edit",
@@ -29660,9 +31049,12 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29660
31049
  config,
29661
31050
  logger
29662
31051
  });
31052
+ const trustAffecting = [];
29663
31053
  const overridePermissions = config.claudecode?.permissions;
29664
31054
  if (overridePermissions && typeof overridePermissions === "object") {
29665
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = overridePermissions;
31055
+ const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
31056
+ const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
31057
+ trustAffecting.push(...collectWideningPermissionFields({ fields: nonListFields }));
29666
31058
  settings.permissions = {
29667
31059
  ...settings.permissions,
29668
31060
  ...nonListFields
@@ -29670,17 +31062,44 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29670
31062
  }
29671
31063
  const overrideSandbox = config.claudecode?.sandbox;
29672
31064
  if (isPlainRecord(overrideSandbox)) {
29673
- const scopedSandbox = global ? overrideSandbox : stripProjectIgnoredMaskEntries({
29674
- sandbox: stripGlobalOnlySandboxPaths({
29675
- sandbox: overrideSandbox,
29676
- relativeFilePath: paths.relativeFilePath,
29677
- logger
29678
- }),
31065
+ const honorableSandbox = stripSandboxPaths({
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
+ ],
29679
31072
  relativeFilePath: paths.relativeFilePath,
29680
31073
  logger
29681
31074
  });
31075
+ const scopedSandbox = global ? honorableSandbox : stripProjectIgnoredMaskEntries({
31076
+ sandbox: honorableSandbox,
31077
+ relativeFilePath: paths.relativeFilePath,
31078
+ logger
31079
+ });
31080
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
29682
31081
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
29683
31082
  }
31083
+ const overrideTopLevel = {};
31084
+ for (const [key, value] of Object.entries(config.claudecode ?? {})) {
31085
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
31086
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
31087
+ if (value === void 0) continue;
31088
+ overrideTopLevel[key] = value;
31089
+ }
31090
+ const { filtered: scopedTopLevel, trustAffecting: trustAffectingTopLevel } = stripUnhonoredTopLevelKeys({
31091
+ overrides: overrideTopLevel,
31092
+ global,
31093
+ relativeFilePath: paths.relativeFilePath,
31094
+ logger
31095
+ });
31096
+ trustAffecting.push(...trustAffectingTopLevel);
31097
+ if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
31098
+ warnOnTrustAffectingEntries({
31099
+ entries: trustAffecting,
31100
+ relativeFilePath: paths.relativeFilePath,
31101
+ logger
31102
+ });
29684
31103
  const managedToolNames = managedClaudeToolNames(config);
29685
31104
  const merged = applyPermissions({
29686
31105
  settings,
@@ -29713,12 +31132,33 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29713
31132
  ask: permissions.ask ?? [],
29714
31133
  deny: permissions.deny ?? []
29715
31134
  });
29716
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
31135
+ const { allow: _a, ask: _k, deny: _d, ...permissionsRest } = permissions;
31136
+ const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
29717
31137
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
29718
31138
  const { sandbox } = settings;
29719
- if (isPlainRecord(sandbox) && Object.keys(sandbox).length > 0) config.claudecode = {
31139
+ if (isPlainRecord(sandbox)) {
31140
+ const importedSandbox = structuredClone(sandbox);
31141
+ for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
31142
+ target: importedSandbox,
31143
+ path
31144
+ });
31145
+ if (Object.keys(importedSandbox).length > 0) config.claudecode = {
31146
+ ...config.claudecode,
31147
+ sandbox: importedSandbox
31148
+ };
31149
+ }
31150
+ const topLevelPassthrough = {};
31151
+ for (const [key, value] of Object.entries(settings)) {
31152
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
31153
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
31154
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) continue;
31155
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
31156
+ if (value === void 0) continue;
31157
+ topLevelPassthrough[key] = value;
31158
+ }
31159
+ if (Object.keys(topLevelPassthrough).length > 0) config.claudecode = {
29720
31160
  ...config.claudecode,
29721
- sandbox
31161
+ ...topLevelPassthrough
29722
31162
  };
29723
31163
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
29724
31164
  }
@@ -34637,11 +36077,8 @@ const TOOL_KEY_TO_CATEGORY = {
34637
36077
  updateConfluencePage: "edit"
34638
36078
  };
34639
36079
  const MANAGED_TOOL_KEYS = [.../* @__PURE__ */ new Set([...Object.values(CATEGORY_TO_TOOL_KEYS).flat(), ...Object.keys(TOOL_KEY_TO_CATEGORY)])];
34640
- const OWNED_TOOL_PERMISSION_KEYS = [
34641
- "bash",
34642
- "allowedExternalPaths",
34643
- "default"
34644
- ];
36080
+ const OWNED_TOOL_PERMISSION_KEYS = ["allowedExternalPaths", "default"];
36081
+ const MANAGED_BASH_KEYS = ["default", "commands"];
34645
36082
  /**
34646
36083
  * Permissions adapter for Rovo Dev CLI.
34647
36084
  *
@@ -34777,6 +36214,22 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
34777
36214
  }
34778
36215
  };
34779
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
+ /**
34780
36233
  * Resolve the `toolPermissions` block to write, merging the generated levels
34781
36234
  * over the existing file. Every other top-level key of `config.yml` is the
34782
36235
  * caller's to preserve; inside this block, keys rulesync manages are owned and
@@ -34784,6 +36237,11 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
34784
36237
  */
34785
36238
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
34786
36239
  const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
36240
+ warnAboutPreservedSandboxOptOut({
36241
+ existingToolPermissions,
36242
+ filePath,
36243
+ logger
36244
+ });
34787
36245
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
34788
36246
  if (!isRecord$1(existing)) return;
34789
36247
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
@@ -34792,9 +36250,12 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34792
36250
  }
34793
36251
  const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
34794
36252
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
36253
+ const hasExistingBashRecord = isRecord$1(existingToolPermissions.bash);
36254
+ const existingBash = hasExistingBashRecord ? { ...existingToolPermissions.bash } : {};
34795
36255
  warnAboutDroppedOwnedKeys({
34796
36256
  existingToolPermissions,
34797
36257
  existingTools,
36258
+ existingBash,
34798
36259
  generated,
34799
36260
  filePath,
34800
36261
  logger
@@ -34804,15 +36265,22 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34804
36265
  delete existingToolPermissions[toolKey];
34805
36266
  delete existingTools[toolKey];
34806
36267
  }
36268
+ for (const bashKey of MANAGED_BASH_KEYS) delete existingBash[bashKey];
34807
36269
  const tools = {
34808
36270
  ...existingTools,
34809
36271
  ...generated.tools
34810
36272
  };
36273
+ const bash = {
36274
+ ...existingBash,
36275
+ ...generated.bash
36276
+ };
34811
36277
  if (hasExistingToolsRecord) delete existingToolPermissions.tools;
36278
+ if (hasExistingBashRecord) delete existingToolPermissions.bash;
34812
36279
  return {
34813
36280
  ...existingToolPermissions,
34814
36281
  ...generated,
34815
- ...Object.keys(tools).length > 0 ? { tools } : {}
36282
+ ...Object.keys(tools).length > 0 ? { tools } : {},
36283
+ ...Object.keys(bash).length > 0 ? { bash } : {}
34816
36284
  };
34817
36285
  }
34818
36286
  /**
@@ -34821,9 +36289,14 @@ function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, f
34821
36289
  * `/directories` and by an "always allow" answer to a prompt — so their removal
34822
36290
  * must not be silent.
34823
36291
  */
34824
- function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, generated, filePath, logger }) {
36292
+ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, existingBash, generated, filePath, logger }) {
34825
36293
  const newTools = generated.tools ?? {};
34826
- 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
+ ];
34827
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.`);
34828
36301
  }
34829
36302
  /**
@@ -36931,10 +38404,10 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
36931
38404
  var PermissionsProcessor = class extends FeatureProcessor {
36932
38405
  toolTarget;
36933
38406
  global;
36934
- 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 }) {
36935
38408
  super({
36936
38409
  outputRoot,
36937
- inputRoot,
38410
+ inputRoots,
36938
38411
  dryRun,
36939
38412
  logger
36940
38413
  });
@@ -36944,9 +38417,17 @@ var PermissionsProcessor = class extends FeatureProcessor {
36944
38417
  this.global = global;
36945
38418
  }
36946
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];
36947
38427
  try {
36948
38428
  return [await RulesyncPermissions.fromFile({
36949
- outputRoot: this.inputRoot,
38429
+ outputRoot: (0, node_path.dirname)(sourceTree),
38430
+ relativeDirPath: (0, node_path.basename)(sourceTree),
36950
38431
  validate: true
36951
38432
  })];
36952
38433
  } catch (error) {
@@ -37370,13 +38851,26 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
37370
38851
  //#region src/types/dir-feature-processor.ts
37371
38852
  var DirFeatureProcessor = class {
37372
38853
  outputRoot;
37373
- 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;
37374
38868
  dryRun;
37375
38869
  avoidBlockScalars;
37376
38870
  logger;
37377
- constructor({ outputRoot = process.cwd(), inputRoot = process.cwd(), dryRun = false, avoidBlockScalars = false, logger }) {
38871
+ constructor({ outputRoot = process.cwd(), inputRoots, dryRun = false, avoidBlockScalars = false, logger }) {
37378
38872
  this.outputRoot = outputRoot;
37379
- 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)];
37380
38874
  this.dryRun = dryRun;
37381
38875
  this.avoidBlockScalars = avoidBlockScalars;
37382
38876
  this.logger = logger;
@@ -39635,9 +41129,9 @@ var DevinSkill = class DevinSkill extends ToolSkill {
39635
41129
  * slug is owned by the commands feature: it must not be imported as a
39636
41130
  * skill nor deleted as an orphan skill.
39637
41131
  */
39638
- static async isDirOwned({ dirName, inputRoot }) {
41132
+ static async isDirOwned({ dirName, inputRoots }) {
39639
41133
  return !await rulesyncCommandSlugExists({
39640
- inputRoot,
41134
+ inputRoots,
39641
41135
  dirName
39642
41136
  });
39643
41137
  }
@@ -39687,7 +41181,11 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
39687
41181
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
39688
41182
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
39689
41183
  enabled: zod_mini.z.optional(zod_mini.z.boolean()),
39690
- "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())
39691
41189
  });
39692
41190
  /**
39693
41191
  * Represents a Factory Droid skill directory.
@@ -39740,16 +41238,10 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
39740
41238
  };
39741
41239
  }
39742
41240
  toRulesyncSkill() {
39743
- const frontmatter = this.getFrontmatter();
39744
- const factorydroidBlock = {
39745
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
39746
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
39747
- ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
39748
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
39749
- };
41241
+ const { name, description, ...factorydroidBlock } = this.getFrontmatter();
39750
41242
  const rulesyncFrontmatter = {
39751
- name: frontmatter.name,
39752
- description: frontmatter.description,
41243
+ name,
41244
+ description,
39753
41245
  targets: ["*"],
39754
41246
  ...Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }
39755
41247
  };
@@ -39776,13 +41268,13 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
39776
41268
  rootFrontmatter: rulesyncFrontmatter,
39777
41269
  section: factorydroidSection
39778
41270
  });
41271
+ const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
39779
41272
  const factorydroidFrontmatter = {
39780
41273
  name: rulesyncFrontmatter.name,
39781
41274
  description: rulesyncFrontmatter.description,
41275
+ ...section,
39782
41276
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
39783
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
39784
- ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
39785
- ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
41277
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
39786
41278
  };
39787
41279
  return new FactorydroidSkill({
39788
41280
  outputRoot,
@@ -40194,7 +41686,10 @@ var JunieSkill = class JunieSkill extends ToolSkill {
40194
41686
  }
40195
41687
  }
40196
41688
  static getSettablePaths(_options) {
40197
- return { relativeDirPath: JUNIE_SKILLS_DIR_PATH };
41689
+ return {
41690
+ relativeDirPath: JUNIE_SKILLS_DIR_PATH,
41691
+ importOnlySkillRoots: [AGENTSMD_SKILLS_DIR_PATH]
41692
+ };
40198
41693
  }
40199
41694
  getFrontmatter() {
40200
41695
  return JunieSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -42356,9 +43851,9 @@ var WarpSkill = class WarpSkill extends ToolSkill {
42356
43851
  * slug is owned by the commands feature: it must not be imported as a
42357
43852
  * skill nor deleted as an orphan skill.
42358
43853
  */
42359
- static async isDirOwned({ dirName, inputRoot }) {
43854
+ static async isDirOwned({ dirName, inputRoots }) {
42360
43855
  return !await rulesyncCommandSlugExists({
42361
- inputRoot,
43856
+ inputRoots,
42362
43857
  dirName
42363
43858
  });
42364
43859
  }
@@ -42916,10 +44411,10 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42916
44411
  toolTarget;
42917
44412
  global;
42918
44413
  getFactory;
42919
- 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 }) {
42920
44415
  super({
42921
44416
  outputRoot,
42922
- inputRoot,
44417
+ inputRoots,
42923
44418
  dryRun,
42924
44419
  avoidBlockScalars: toolTarget === "cursor",
42925
44420
  logger
@@ -42957,39 +44452,67 @@ var SkillsProcessor = class extends DirFeatureProcessor {
42957
44452
  return rulesyncSkills;
42958
44453
  }
42959
44454
  /**
42960
- * Implementation of abstract method from DirFeatureProcessor
42961
- * Load and parse rulesync skill directories from .rulesync/skills/ directory
42962
- * and also from .rulesync/skills/.curated/ for remote skills.
42963
- * 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.
42964
44459
  */
42965
- async loadRulesyncDirs() {
42966
- 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)];
42967
44466
  const localSkills = await Promise.all(localDirNames.map((dirName) => RulesyncSkill.fromDir({
42968
- outputRoot: this.inputRoot,
44467
+ outputRoot: treeParent,
44468
+ relativeDirPath: treeSkillsDirPath,
42969
44469
  dirName,
42970
44470
  global: this.global
42971
44471
  })));
42972
- const localSkillNames = new Set(localDirNames);
42973
- 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);
42974
44474
  let curatedSkills = [];
42975
44475
  if (await directoryExists(curatedDirPath)) {
42976
44476
  const nonConflicting = (await findFilesByGlobs((0, node_path.join)(curatedDirPath, "*"), { type: "dir" })).map((path) => (0, node_path.basename)(path)).filter((name) => {
42977
- if (localSkillNames.has(name)) {
42978
- this.logger.debug(`Skipping curated skill "${name}": local skill takes precedence.`);
42979
- return false;
42980
- }
42981
- 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;
42982
44488
  });
42983
- const curatedRelativeDirPath = RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH;
42984
44489
  curatedSkills = await Promise.all(nonConflicting.map((dirName) => RulesyncSkill.fromDir({
42985
- outputRoot: this.inputRoot,
42986
- relativeDirPath: curatedRelativeDirPath,
44490
+ outputRoot: treeParent,
44491
+ relativeDirPath: treeCuratedSkillsDirPath,
42987
44492
  dirName,
42988
44493
  global: this.global
42989
44494
  })));
42990
44495
  }
42991
- const allSkills = [...localSkills, ...curatedSkills];
42992
- 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`);
42993
44516
  return allSkills;
42994
44517
  }
42995
44518
  /**
@@ -43005,7 +44528,17 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43005
44528
  }) : [];
43006
44529
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
43007
44530
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
43008
- 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
+ };
43009
44542
  const toolSkills = [];
43010
44543
  for (const root of roots) {
43011
44544
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
@@ -43021,54 +44554,60 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43021
44554
  outputRoot: rootOutputRoot,
43022
44555
  relativeDirPath,
43023
44556
  dirName,
43024
- inputRoot: this.inputRoot
44557
+ inputRoots: this.inputRoots
43025
44558
  })) continue;
43026
44559
  ownedDirNames.push(dirName);
43027
44560
  }
43028
44561
  const directorySkills = (await Promise.all(ownedDirNames.map(async (dirName) => {
44562
+ const sourcePath = (0, node_path.join)(relativeDirPath, dirName);
43029
44563
  try {
43030
- return await factory.class.fromDir({
43031
- outputRoot: rootOutputRoot,
43032
- relativeDirPath,
43033
- dirName,
43034
- global: this.global
43035
- });
44564
+ return {
44565
+ skill: await factory.class.fromDir({
44566
+ outputRoot: rootOutputRoot,
44567
+ relativeDirPath,
44568
+ dirName,
44569
+ global: this.global
44570
+ }),
44571
+ sourcePath
44572
+ };
43036
44573
  } catch (error) {
43037
44574
  if (!isLenientRoot) throw error;
43038
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, dirName)}: ${formatError(error)}`);
44575
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43039
44576
  return null;
43040
44577
  }
43041
- }))).filter((skill) => skill !== null);
43042
- for (const skill of directorySkills) {
43043
- const skillName = skill.getImportIdentity();
43044
- if (seenSkillNames.has(skillName)) continue;
43045
- seenSkillNames.add(skillName);
43046
- toolSkills.push(skill);
43047
- }
44578
+ }))).filter((loaded) => loaded !== null);
44579
+ for (const { skill, sourcePath } of directorySkills) if (claimSkillName({
44580
+ skill,
44581
+ relativeDirPath,
44582
+ sourcePath
44583
+ })) toolSkills.push(skill);
43048
44584
  if (!factory.class.fromFlatFile) continue;
43049
44585
  const fromFlatFile = factory.class.fromFlatFile;
43050
44586
  const directoryStems = new Set(ownedDirNames);
43051
44587
  const flatFilePaths = (await findFilesByGlobs((0, node_path.join)(skillsDirPath, "*.md"), { type: "file" })).filter((filePath) => !directoryStems.has((0, node_path.basename)(filePath, ".md")));
43052
44588
  const flatSkills = (await Promise.all(flatFilePaths.map(async (filePath) => {
44589
+ const sourcePath = (0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath));
43053
44590
  try {
43054
- return await fromFlatFile({
43055
- outputRoot: rootOutputRoot,
43056
- relativeDirPath,
43057
- relativeFilePath: (0, node_path.basename)(filePath),
43058
- global: this.global
43059
- });
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
+ };
43060
44600
  } catch (error) {
43061
44601
  if (!isLenientRoot) throw error;
43062
- this.logger.warn(`Skipping ${(0, node_path.join)(relativeDirPath, (0, node_path.basename)(filePath))}: ${formatError(error)}`);
44602
+ this.logger.warn(`Skipping ${sourcePath}: ${formatError(error)}`);
43063
44603
  return null;
43064
44604
  }
43065
- }))).filter((skill) => skill !== null);
43066
- for (const skill of flatSkills) {
43067
- const skillName = skill.getImportIdentity();
43068
- if (seenSkillNames.has(skillName)) continue;
43069
- seenSkillNames.add(skillName);
43070
- toolSkills.push(skill);
43071
- }
44605
+ }))).filter((loaded) => loaded !== null);
44606
+ for (const { skill, sourcePath } of flatSkills) if (claimSkillName({
44607
+ skill,
44608
+ relativeDirPath,
44609
+ sourcePath
44610
+ })) toolSkills.push(skill);
43072
44611
  }
43073
44612
  this.logger.debug(`Successfully loaded ${toolSkills.length} skills from ${roots.length} root(s)`);
43074
44613
  return toolSkills;
@@ -43098,7 +44637,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
43098
44637
  outputRoot: this.outputRoot,
43099
44638
  relativeDirPath: root,
43100
44639
  dirName,
43101
- inputRoot: this.inputRoot
44640
+ inputRoots: this.inputRoots
43102
44641
  })) continue;
43103
44642
  const toolSkill = factory.class.forDeletion({
43104
44643
  outputRoot: this.outputRoot,
@@ -45856,6 +47395,7 @@ const JunieSubagentFrontmatterSchema = zod_mini.z.looseObject({
45856
47395
  disallowedTools: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
45857
47396
  mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
45858
47397
  model: zod_mini.z.optional(zod_mini.z.string()),
47398
+ permissionMode: zod_mini.z.optional(zod_mini.z.string()),
45859
47399
  reasoningLevel: zod_mini.z.optional(zod_mini.z.string()),
45860
47400
  maxTurns: zod_mini.z.optional(zod_mini.z.number()),
45861
47401
  skills: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
@@ -47836,14 +49376,25 @@ const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((targe
47836
49376
  const subagentsProcessorToolTargetsGlobal = allToolTargetKeys$1.filter((target) => {
47837
49377
  return toolSubagentFactories.get(target)?.meta.supportsGlobal ?? false;
47838
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>";
47839
49390
  var SubagentsProcessor = class extends FeatureProcessor {
47840
49391
  toolTarget;
47841
49392
  global;
47842
49393
  getFactory;
47843
- 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 }) {
47844
49395
  super({
47845
49396
  outputRoot,
47846
- inputRoot,
49397
+ inputRoots,
47847
49398
  dryRun,
47848
49399
  logger
47849
49400
  });
@@ -47889,24 +49440,31 @@ var SubagentsProcessor = class extends FeatureProcessor {
47889
49440
  rulesyncSubagents.push(toolSubagent.toRulesyncSubagent());
47890
49441
  }
47891
49442
  const uniqueRulesyncSubagents = [];
47892
- const seenOutputPaths = /* @__PURE__ */ new Set();
49443
+ const claimedOutputPaths = new ClaimedIdentities();
47893
49444
  for (const rulesyncSubagent of rulesyncSubagents) {
47894
49445
  const outputPath = (0, node_path.join)(rulesyncSubagent.getRelativeDirPath(), rulesyncSubagent.getRelativeFilePath());
47895
- if (seenOutputPaths.has(outputPath)) {
47896
- 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.`);
47897
49452
  continue;
47898
49453
  }
47899
- seenOutputPaths.add(outputPath);
47900
49454
  uniqueRulesyncSubagents.push(rulesyncSubagent);
47901
49455
  }
47902
49456
  return uniqueRulesyncSubagents;
47903
49457
  }
47904
49458
  /**
47905
- * Implementation of abstract method from Processor
47906
- * 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`).
47907
49462
  */
47908
- async loadRulesyncFiles() {
47909
- 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);
47910
49468
  if (!await directoryExists(subagentsDir)) {
47911
49469
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
47912
49470
  return [];
@@ -47922,7 +49480,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
47922
49480
  const filepath = (0, node_path.join)(subagentsDir, mdFile);
47923
49481
  try {
47924
49482
  const rulesyncSubagent = await RulesyncSubagent.fromFile({
47925
- outputRoot: this.inputRoot,
49483
+ outputRoot: treeParent,
49484
+ relativeDirPath: treeSubagentsDirPath,
47926
49485
  relativeFilePath: mdFile,
47927
49486
  validate: true
47928
49487
  });
@@ -47933,8 +49492,24 @@ var SubagentsProcessor = class extends FeatureProcessor {
47933
49492
  continue;
47934
49493
  }
47935
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
+ });
47936
49511
  if (rulesyncSubagents.length === 0) {
47937
- this.logger.debug(`No valid subagents found in ${subagentsDir}`);
49512
+ this.logger.debug(`No valid subagents found`);
47938
49513
  return [];
47939
49514
  }
47940
49515
  this.logger.debug(`Successfully loaded ${rulesyncSubagents.length} rulesync subagents`);
@@ -47949,7 +49524,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
47949
49524
  const paths = factory.class.getSettablePaths({ global: this.global });
47950
49525
  const roots = forDeletion ? [paths.relativeDirPath] : [paths.relativeDirPath, ...paths.importDirPaths ?? []];
47951
49526
  const toolSubagents = [];
47952
- const seenRelativeFilePaths = /* @__PURE__ */ new Set();
49527
+ const claimedRelativeFilePaths = new ClaimedIdentities();
47953
49528
  for (const root of roots) {
47954
49529
  const rootOutputRoot = typeof root === "string" ? this.outputRoot : root.outputRoot;
47955
49530
  const dirPath = typeof root === "string" ? root : root.relativeDirPath;
@@ -47988,37 +49563,77 @@ var SubagentsProcessor = class extends FeatureProcessor {
47988
49563
  relativeFilePath: toRelativeFilePath(path),
47989
49564
  global: this.global
47990
49565
  })));
47991
- const deduped = [];
47992
- for (const subagent of loaded) {
47993
- const key = subagent.getImportIdentity();
47994
- if (seenRelativeFilePaths.has(key)) {
47995
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" found in ${dirPath}; keeping the one from a higher-precedence directory and ignoring this copy.`);
47996
- continue;
47997
- }
47998
- seenRelativeFilePaths.add(key);
47999
- deduped.push(subagent);
48000
- }
48001
- toolSubagents.push(...deduped);
49566
+ toolSubagents.push(...this.claimStandaloneSubagents({
49567
+ loaded,
49568
+ dirPath,
49569
+ claimedRelativeFilePaths
49570
+ }));
48002
49571
  }
48003
49572
  if (!forDeletion && factory.class.loadAdditionalImportFiles) {
48004
49573
  const additionalSubagents = await factory.class.loadAdditionalImportFiles({
48005
49574
  outputRoot: this.outputRoot,
48006
49575
  global: this.global
48007
49576
  });
48008
- for (const subagent of additionalSubagents) {
48009
- const key = subagent.getImportIdentity();
48010
- if (seenRelativeFilePaths.has(key)) {
48011
- this.logger.warn(`Duplicate ${this.toolTarget} subagent "${key}" defined inline; keeping the standalone file and ignoring the inline copy.`);
48012
- continue;
48013
- }
48014
- seenRelativeFilePaths.add(key);
48015
- toolSubagents.push(subagent);
48016
- }
49577
+ toolSubagents.push(...this.claimInlineSubagents({
49578
+ additionalSubagents,
49579
+ claimedRelativeFilePaths
49580
+ }));
48017
49581
  }
48018
49582
  this.logger.debug(`Successfully loaded ${toolSubagents.length} ${this.toolTarget} subagents from ${roots.length} root(s)`);
48019
49583
  return toolSubagents;
48020
49584
  }
48021
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
+ /**
48022
49637
  * Implementation of abstract method from FeatureProcessor
48023
49638
  * Return the tool targets that this processor supports
48024
49639
  */
@@ -50948,17 +52563,32 @@ var HermesagentRule = class HermesagentRule extends ToolRule {
50948
52563
  * Rule generator for JetBrains Junie AI coding agent
50949
52564
  *
50950
52565
  * Generates `.junie/AGENTS.md` files based on rulesync rule content. Junie CLI
50951
- * resolves project guidelines **first-match-wins**: `.junie/AGENTS.md` → root
50952
- * `AGENTS.md` legacy `.junie/guidelines.md` / `.junie/guidelines/`. Only the
50953
- * first match is loaded, it documents no `@`-reference or file-inclusion
50954
- * mechanism, and no `.junie/memories/` read path exists so non-root rules
50955
- * are folded into the single root `.junie/AGENTS.md` by the RulesProcessor
50956
- * (`nonRoot` is `undefined`, mirroring the warp / deepagents
50957
- * 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
50958
52580
  * `.junie/guidelines.md` is still accepted as an import fallback, but
50959
52581
  * generation always targets `.junie/AGENTS.md`. Junie uses plain markdown
50960
52582
  * without frontmatter requirements.
50961
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
+ *
50962
52592
  * Global (user) scope writes a single `~/.junie/AGENTS.md` file. Junie merges
50963
52593
  * these user-scope guidelines with the project guidelines (both are included
50964
52594
  * and marked clearly).
@@ -50980,6 +52610,14 @@ var JunieRule = class JunieRule extends ToolRule {
50980
52610
  alternativeRoots: [{
50981
52611
  relativeDirPath: buildToolPath(JUNIE_DIR, ".", excludeToolDir),
50982
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
50983
52621
  }]
50984
52622
  };
50985
52623
  }
@@ -50990,7 +52628,7 @@ var JunieRule = class JunieRule extends ToolRule {
50990
52628
  static isRootRelativeFilePath(relativeFilePath) {
50991
52629
  return relativeFilePath === "AGENTS.md" || relativeFilePath === "guidelines.md";
50992
52630
  }
50993
- 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 }) {
50994
52632
  if (global) {
50995
52633
  const paths = this.getSettablePaths({ global: true });
50996
52634
  if (!("root" in paths) || !paths.root) throw new Error("JunieRule global settable paths must include a root path");
@@ -51004,7 +52642,8 @@ var JunieRule = class JunieRule extends ToolRule {
51004
52642
  root: true
51005
52643
  });
51006
52644
  }
51007
- const relativeDirPath = this.getSettablePaths().root.relativeDirPath;
52645
+ const settablePaths = this.getSettablePaths();
52646
+ const relativeDirPath = relativeDirPathParam ?? settablePaths.root.relativeDirPath;
51008
52647
  const relativePath = (0, node_path.join)(relativeDirPath, relativeFilePath);
51009
52648
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
51010
52649
  return new JunieRule({
@@ -51013,7 +52652,7 @@ var JunieRule = class JunieRule extends ToolRule {
51013
52652
  relativeFilePath,
51014
52653
  fileContent,
51015
52654
  validate,
51016
- root: JunieRule.isRootRelativeFilePath(relativeFilePath)
52655
+ root: relativeDirPath === settablePaths.root.relativeDirPath && JunieRule.isRootRelativeFilePath(relativeFilePath)
51017
52656
  });
51018
52657
  }
51019
52658
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -53365,10 +55004,23 @@ const defaultGetFactory = (target) => {
53365
55004
  if (!factory) throw new Error(`Unsupported tool target: ${target}`);
53366
55005
  return factory;
53367
55006
  };
53368
- const findFilesWithFallback = async (primaryGlob, alternativeRoots, buildAltGlob) => {
53369
- 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) => {
53370
55022
  if (primaryFilePaths.length > 0) return primaryFilePaths;
53371
- if (alternativeRoots) return findFilesByGlobs(alternativeRoots.map(buildAltGlob));
55023
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
53372
55024
  return [];
53373
55025
  };
53374
55026
  var RulesProcessor = class extends FeatureProcessor {
@@ -53380,10 +55032,10 @@ var RulesProcessor = class extends FeatureProcessor {
53380
55032
  getFactory;
53381
55033
  skills;
53382
55034
  featureOptions;
53383
- 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 }) {
53384
55036
  super({
53385
55037
  outputRoot,
53386
- inputRoot,
55038
+ inputRoots,
53387
55039
  dryRun,
53388
55040
  logger
53389
55041
  });
@@ -53758,25 +55410,45 @@ As this project's AI coding tool, you must follow the additional conventions bel
53758
55410
  claimedBy.set(target.toLowerCase(), source);
53759
55411
  continue;
53760
55412
  }
53761
- 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.`);
53762
55414
  }
53763
55415
  return rulesyncRules;
53764
55416
  }
53765
55417
  /**
53766
- * Implementation of abstract method from FeatureProcessor
53767
- * 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.
53768
55426
  */
53769
- async loadRulesyncFiles() {
53770
- const rulesyncOutputRoot = (0, node_path.join)(this.inputRoot, RULESYNC_RULES_RELATIVE_DIR_PATH);
53771
- 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);
53772
55433
  const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([findFilesByGlobs((0, node_path.join)(rulesyncOutputRoot, "**", "*.md")), findFilesByGlobs((0, node_path.join)(curatedOutputRoot, "**", "*.md"))]);
53773
55434
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
53774
55435
  const localFiles = files.filter((file) => !(0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`));
53775
- 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)));
53776
55437
  const curatedFiles = files.filter((file) => (0, node_path.relative)(rulesyncOutputRoot, file).startsWith(`.curated${node_path.sep}`)).map((file) => ({
53777
55438
  file,
53778
55439
  relativeFilePath: (0, node_path.relative)(curatedOutputRoot, file)
53779
- })).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
+ });
53780
55452
  const selectedFiles = [...localFiles.map((file) => ({
53781
55453
  file,
53782
55454
  sourceRelativeFilePath: (0, node_path.relative)(rulesyncOutputRoot, file),
@@ -53786,25 +55458,41 @@ As this project's AI coding tool, you must follow the additional conventions bel
53786
55458
  sourceRelativeFilePath: (0, node_path.join)(".curated", relativeFilePath),
53787
55459
  relativeFilePath
53788
55460
  }))];
53789
- this.logger.debug(`Found ${selectedFiles.length} rulesync files`);
53790
- 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 }) => {
53791
55463
  checkPathTraversal({
53792
55464
  relativePath: sourceRelativeFilePath,
53793
55465
  intendedRootDir: rulesyncOutputRoot
53794
55466
  });
53795
55467
  const rule = await RulesyncRule.fromFile({
53796
- outputRoot: this.inputRoot,
55468
+ outputRoot: treeParent,
55469
+ relativeDirPath: treeRulesDirPath,
53797
55470
  relativeFilePath: sourceRelativeFilePath
53798
55471
  });
53799
55472
  if (sourceRelativeFilePath === relativeFilePath) return rule;
53800
55473
  return new RulesyncRule({
53801
- outputRoot: this.inputRoot,
53802
- relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
55474
+ outputRoot: treeParent,
55475
+ relativeDirPath: treeRulesDirPath,
53803
55476
  relativeFilePath,
53804
55477
  frontmatter: rule.getFrontmatter(),
53805
55478
  body: rule.getBody()
53806
55479
  });
53807
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
+ });
53808
55496
  const factory = this.getFactory(this.toolTarget);
53809
55497
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
53810
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}.`);
@@ -53898,23 +55586,42 @@ As this project's AI coding tool, you must follow the additional conventions bel
53898
55586
  });
53899
55587
  }).filter((rule) => rule.isDeletable());
53900
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)) : [];
53901
55620
  const rootToolRules = await (async () => {
53902
55621
  if (!settablePaths.root) return [];
53903
- 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));
53904
55623
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
53905
- return await Promise.all(uniqueRootFilePaths.map((filePath) => {
53906
- const relativeDirPath = resolveRelativeDirPath(filePath);
53907
- checkPathTraversal({
53908
- relativePath: relativeDirPath,
53909
- intendedRootDir: this.outputRoot
53910
- });
53911
- return factory.class.fromFile({
53912
- outputRoot: this.outputRoot,
53913
- relativeFilePath: (0, node_path.basename)(filePath),
53914
- relativeDirPath,
53915
- global: this.global
53916
- });
53917
- }));
55624
+ return await buildImportRulesFromPaths(uniqueRootFilePaths);
53918
55625
  })();
53919
55626
  this.logger.debug(`Found ${rootToolRules.length} root tool rule files`);
53920
55627
  const localRootToolRules = await (async () => {
@@ -53926,7 +55633,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
53926
55633
  fileName
53927
55634
  }));
53928
55635
  if (!settablePaths.root) return [];
53929
- 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));
53930
55637
  })();
53931
55638
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
53932
55639
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -54002,6 +55709,29 @@ As this project's AI coding tool, you must follow the additional conventions bel
54002
55709
  }));
54003
55710
  })();
54004
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`);
54005
55735
  const nonRootToolRules = await (async () => {
54006
55736
  if (!settablePaths.nonRoot) return [];
54007
55737
  const nonRootOutputRoot = (0, node_path.join)(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
@@ -54034,6 +55764,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
54034
55764
  })();
54035
55765
  this.logger.debug(`Found ${nonRootToolRules.length} non-root tool rule files`);
54036
55766
  return [
55767
+ ...importOnlyToolRules,
54037
55768
  ...rootToolRules,
54038
55769
  ...localRootToolRules,
54039
55770
  ...rootMirrorDeletionRules,
@@ -54841,14 +56572,53 @@ function warnUnsupportedTargets(params) {
54841
56572
  }
54842
56573
  }
54843
56574
  /**
54844
- * Check if .rulesync directory exists.
54845
- *
54846
- * The `.rulesync/` directory lives under the *input* root (where source rules
54847
- * are read from), not under any individual output root, so callers always pass
54848
- * `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.
54849
56581
  */
54850
- async function checkRulesyncDirExists(params) {
54851
- 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
+ };
54852
56622
  }
54853
56623
  function dependsOnReachable(byId, from, target) {
54854
56624
  const seen = /* @__PURE__ */ new Set();
@@ -54979,7 +56749,7 @@ async function warnSkillSubagentNameCollisions(params) {
54979
56749
  const subagentsDirPath = subagentFactory.class.getSettablePaths({ global }).relativeDirPath;
54980
56750
  if (subagentsDirPath !== skillFactory.class.getSettablePaths({ global }).relativeDirPath) continue;
54981
56751
  const subagentsProcessor = new SubagentsProcessor({
54982
- inputRoot: config.getInputRoot(),
56752
+ inputRoots: config.getInputRoots(),
54983
56753
  toolTarget,
54984
56754
  global,
54985
56755
  logger
@@ -54987,7 +56757,7 @@ async function warnSkillSubagentNameCollisions(params) {
54987
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()))));
54988
56758
  if (subagentNames.size === 0) continue;
54989
56759
  const skillNames = (await new SkillsProcessor({
54990
- inputRoot: config.getInputRoot(),
56760
+ inputRoots: config.getInputRoots(),
54991
56761
  toolTarget,
54992
56762
  global,
54993
56763
  logger
@@ -55036,6 +56806,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
55036
56806
  */
55037
56807
  async function generate(params) {
55038
56808
  const { config, logger } = params;
56809
+ resetRootShadowingWarnings({ logger });
55039
56810
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
55040
56811
  toolTarget,
55041
56812
  outputRoot
@@ -55174,7 +56945,7 @@ async function generateRulesCore(params) {
55174
56945
  toolTarget,
55175
56946
  global: config.getGlobal()
55176
56947
  }),
55177
- inputRoot: config.getInputRoot(),
56948
+ inputRoots: config.getInputRoots(),
55178
56949
  toolTarget,
55179
56950
  global: config.getGlobal(),
55180
56951
  simulateCommands: config.getSimulateCommands(),
@@ -55224,7 +56995,7 @@ async function generateIgnoreCore(params) {
55224
56995
  for (const outputRoot of config.getOutputRoots(toolTarget)) try {
55225
56996
  const processor = new IgnoreProcessor({
55226
56997
  outputRoot,
55227
- inputRoot: config.getInputRoot(),
56998
+ inputRoots: config.getInputRoots(),
55228
56999
  toolTarget,
55229
57000
  global,
55230
57001
  dryRun: config.isPreviewMode(),
@@ -55271,7 +57042,7 @@ async function generateMcpCore(params) {
55271
57042
  toolTarget,
55272
57043
  global: config.getGlobal()
55273
57044
  }),
55274
- inputRoot: config.getInputRoot(),
57045
+ inputRoots: config.getInputRoots(),
55275
57046
  toolTarget,
55276
57047
  global: config.getGlobal(),
55277
57048
  dryRun: config.isPreviewMode(),
@@ -55317,7 +57088,7 @@ async function generateCommandsCore(params) {
55317
57088
  toolTarget,
55318
57089
  global: config.getGlobal()
55319
57090
  }),
55320
- inputRoot: config.getInputRoot(),
57091
+ inputRoots: config.getInputRoots(),
55321
57092
  toolTarget,
55322
57093
  global: config.getGlobal(),
55323
57094
  dryRun: config.isPreviewMode(),
@@ -55364,7 +57135,7 @@ async function generateSubagentsCore(params) {
55364
57135
  toolTarget,
55365
57136
  global: config.getGlobal()
55366
57137
  }),
55367
- inputRoot: config.getInputRoot(),
57138
+ inputRoots: config.getInputRoots(),
55368
57139
  toolTarget,
55369
57140
  global: config.getGlobal(),
55370
57141
  dryRun: config.isPreviewMode(),
@@ -55411,7 +57182,7 @@ async function generateSkillsCore(params) {
55411
57182
  toolTarget,
55412
57183
  global: config.getGlobal()
55413
57184
  }),
55414
- inputRoot: config.getInputRoot(),
57185
+ inputRoots: config.getInputRoots(),
55415
57186
  toolTarget,
55416
57187
  global: config.getGlobal(),
55417
57188
  dryRun: config.isPreviewMode(),
@@ -55456,7 +57227,7 @@ async function generateHooksCore(params) {
55456
57227
  toolTarget,
55457
57228
  global: config.getGlobal()
55458
57229
  }),
55459
- inputRoot: config.getInputRoot(),
57230
+ inputRoots: config.getInputRoots(),
55460
57231
  toolTarget,
55461
57232
  global: config.getGlobal(),
55462
57233
  dryRun: config.isPreviewMode(),
@@ -55498,7 +57269,7 @@ async function generatePermissionsCore(params) {
55498
57269
  toolTarget,
55499
57270
  global: config.getGlobal()
55500
57271
  }),
55501
- inputRoot: config.getInputRoot(),
57272
+ inputRoots: config.getInputRoots(),
55502
57273
  toolTarget,
55503
57274
  global: config.getGlobal(),
55504
57275
  dryRun: config.isPreviewMode(),
@@ -55544,7 +57315,7 @@ async function generateChecksCore(params) {
55544
57315
  toolTarget,
55545
57316
  global: config.getGlobal()
55546
57317
  }),
55547
- inputRoot: config.getInputRoot(),
57318
+ inputRoots: config.getInputRoots(),
55548
57319
  toolTarget,
55549
57320
  global: config.getGlobal(),
55550
57321
  dryRun: config.isPreviewMode(),
@@ -56408,12 +58179,6 @@ Object.defineProperty(exports, "checkPathTraversal", {
56408
58179
  return checkPathTraversal;
56409
58180
  }
56410
58181
  });
56411
- Object.defineProperty(exports, "checkRulesyncDirExists", {
56412
- enumerable: true,
56413
- get: function() {
56414
- return checkRulesyncDirExists;
56415
- }
56416
- });
56417
58182
  Object.defineProperty(exports, "convertFromTool", {
56418
58183
  enumerable: true,
56419
58184
  get: function() {
@@ -56510,6 +58275,12 @@ Object.defineProperty(exports, "importFromTool", {
56510
58275
  return importFromTool;
56511
58276
  }
56512
58277
  });
58278
+ Object.defineProperty(exports, "inspectInputRoots", {
58279
+ enumerable: true,
58280
+ get: function() {
58281
+ return inspectInputRoots;
58282
+ }
58283
+ });
56513
58284
  Object.defineProperty(exports, "isPackagingToolTarget", {
56514
58285
  enumerable: true,
56515
58286
  get: function() {
@@ -56534,6 +58305,12 @@ Object.defineProperty(exports, "loadYaml", {
56534
58305
  return loadYaml;
56535
58306
  }
56536
58307
  });
58308
+ Object.defineProperty(exports, "mergeInputRootConfigs", {
58309
+ enumerable: true,
58310
+ get: function() {
58311
+ return mergeInputRootConfigs;
58312
+ }
58313
+ });
56537
58314
  Object.defineProperty(exports, "parseJsonc", {
56538
58315
  enumerable: true,
56539
58316
  get: function() {
@@ -56582,6 +58359,12 @@ Object.defineProperty(exports, "removeTempDirectory", {
56582
58359
  return removeTempDirectory;
56583
58360
  }
56584
58361
  });
58362
+ Object.defineProperty(exports, "resolveEffectiveInputRoots", {
58363
+ enumerable: true,
58364
+ get: function() {
58365
+ return resolveEffectiveInputRoots;
58366
+ }
58367
+ });
56585
58368
  Object.defineProperty(exports, "resolvePath", {
56586
58369
  enumerable: true,
56587
58370
  get: function() {
@@ -56606,6 +58389,12 @@ Object.defineProperty(exports, "stringifyFrontmatter", {
56606
58389
  return stringifyFrontmatter;
56607
58390
  }
56608
58391
  });
58392
+ Object.defineProperty(exports, "stripControlCharacters", {
58393
+ enumerable: true,
58394
+ get: function() {
58395
+ return stripControlCharacters;
58396
+ }
58397
+ });
56609
58398
  Object.defineProperty(exports, "toPosixPath", {
56610
58399
  enumerable: true,
56611
58400
  get: function() {