rulesync 16.21.0 → 16.22.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.
@@ -246,6 +246,35 @@ function hasDeceptiveHiddenCharacters(text) {
246
246
  return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
247
247
  });
248
248
  }
249
+ /** A mark that draws around the character before it — a circle, a square, a keycap box. */
250
+ const ENCLOSING_MARK_PATTERN = /\p{Me}/u;
251
+ /**
252
+ * Whether `text` carries an enclosing mark that is not the keycap of an emoji
253
+ * keycap sequence.
254
+ *
255
+ * An enclosing mark (`\p{Me}`: U+20DD COMBINING ENCLOSING CIRCLE, U+20E3
256
+ * COMBINING ENCLOSING KEYCAP, the Cyrillic and Vedic ones) is drawn over the
257
+ * character before it and takes no column of its own, so `pdf` with one after
258
+ * it occupies the three columns of `pdf` and is a fourth directory underneath.
259
+ * Unlike a joiner or a variation selector it is not invisible — the box is
260
+ * drawn — which is why `hasDeceptiveHiddenCharacters` does not refuse it and
261
+ * why it is a question for the confusable-name note instead: the row is drawn,
262
+ * only not the way its name reads. The one place an enclosing mark belongs in
263
+ * a name is the keycap sequence of UTS #51, which `isKeycapSequence` matches
264
+ * whole; every other one is left over.
265
+ *
266
+ * Restricted to `\p{Me}` on purpose: a non-spacing mark (`\p{Mn}`) is how
267
+ * Devanagari, Arabic and Vietnamese write, and folding those would mark
268
+ * ordinary names in every one of them.
269
+ */
270
+ function hasEnclosingMarkOutsideKeycap(text) {
271
+ const characters = [...text];
272
+ return characters.some((character, index) => ENCLOSING_MARK_PATTERN.test(character) && !isKeycapSequence({
273
+ base: characters[index - 2],
274
+ selector: characters[index - 1] ?? "",
275
+ following: character
276
+ }));
277
+ }
249
278
  //#endregion
250
279
  //#region src/utils/truncate.ts
251
280
  /**
@@ -415,34 +444,34 @@ const isFeatureValueEnabled = (value) => {
415
444
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
416
445
  //#endregion
417
446
  //#region src/constants/rulesync-paths.ts
418
- const { join: join$299 } = node_path.posix;
447
+ const { join: join$304 } = node_path.posix;
419
448
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
420
449
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
421
450
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
422
451
  const RULES_FEATURE_SUBDIR = "rules";
423
- const CURATED_RULES_FEATURE_SUBDIR = join$299(RULES_FEATURE_SUBDIR, ".curated");
452
+ const CURATED_RULES_FEATURE_SUBDIR = join$304(RULES_FEATURE_SUBDIR, ".curated");
424
453
  const COMMANDS_FEATURE_SUBDIR = "commands";
425
454
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
426
455
  const CHECKS_FEATURE_SUBDIR = "checks";
427
456
  const SKILLS_FEATURE_SUBDIR = "skills";
428
- const CURATED_SKILLS_FEATURE_SUBDIR = join$299(SKILLS_FEATURE_SUBDIR, ".curated");
429
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
430
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
431
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
432
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
433
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
434
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
435
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
436
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
437
- join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
438
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
439
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
457
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$304(SKILLS_FEATURE_SUBDIR, ".curated");
458
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
459
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
460
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
461
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
462
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
463
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
464
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
465
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
466
+ join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
467
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
468
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
440
469
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
441
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
470
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
442
471
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
443
472
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
444
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
445
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
473
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
474
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
446
475
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
447
476
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
448
477
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -469,9 +498,11 @@ const rulesProcessorToolTargetTuple = [
469
498
  "claudecode",
470
499
  "claudecode-legacy",
471
500
  "cline",
501
+ "codebuddy",
472
502
  "codexcli",
473
503
  "copilot",
474
504
  "copilotcli",
505
+ "crush",
475
506
  "cursor",
476
507
  "deepagents",
477
508
  "factorydroid",
@@ -507,6 +538,7 @@ const ignoreProcessorToolTargetTuple = [
507
538
  "claudecode",
508
539
  "claudecode-legacy",
509
540
  "cline",
541
+ "crush",
510
542
  "cursor",
511
543
  "hermesagent",
512
544
  "junie",
@@ -648,6 +680,7 @@ const skillsProcessorToolTargetTuple = [
648
680
  "codexcli",
649
681
  "copilot",
650
682
  "copilotcli",
683
+ "crush",
651
684
  "cursor",
652
685
  "deepagents",
653
686
  "factorydroid",
@@ -3352,6 +3385,42 @@ var RulesyncFile = class extends AiFile {
3352
3385
  }
3353
3386
  };
3354
3387
  //#endregion
3388
+ //#region src/utils/prototype-pollution.ts
3389
+ /**
3390
+ * Keys that, if walked into when constructing or merging objects from
3391
+ * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
3392
+ * chain) and propagate state to every other object in the runtime. Any code
3393
+ * that copies arbitrary user-supplied keys into a fresh object — frontmatter
3394
+ * parsing, MCP config conversion, settings round-trip — should skip these.
3395
+ */
3396
+ const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
3397
+ "__proto__",
3398
+ "constructor",
3399
+ "prototype"
3400
+ ]);
3401
+ function isPrototypePollutionKey(key) {
3402
+ return PROTOTYPE_POLLUTION_KEYS.has(key);
3403
+ }
3404
+ /**
3405
+ * Returns a shallow copy of a record's own entries with every
3406
+ * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
3407
+ *
3408
+ * Use when copying a nested, user-supplied string map — an MCP server's `env`
3409
+ * or `headers` table — into freshly generated config. Carrying such a map by
3410
+ * reference, or re-assigning its keys via bracket notation, would let a literal
3411
+ * `__proto__` key ride along (and re-assigning it would mutate the target's
3412
+ * prototype). Walking the entries through this helper severs that path while
3413
+ * preserving every legitimate key.
3414
+ */
3415
+ function omitPrototypePollutionKeys(record) {
3416
+ const sanitized = {};
3417
+ for (const [key, value] of Object.entries(record)) {
3418
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3419
+ sanitized[key] = value;
3420
+ }
3421
+ return sanitized;
3422
+ }
3423
+ //#endregion
3355
3424
  //#region src/utils/type-guards.ts
3356
3425
  /**
3357
3426
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -3416,67 +3485,192 @@ function loadYaml(content) {
3416
3485
  }
3417
3486
  //#endregion
3418
3487
  //#region src/utils/frontmatter.ts
3419
- function deepRemoveNullishValue(value) {
3488
+ /**
3489
+ * Upper bound on the number of values a frontmatter document may expand to
3490
+ * once every YAML alias is written out.
3491
+ *
3492
+ * A YAML alias makes one parsed container reachable from many keys, and the
3493
+ * cleaners below copy each reachable value, so a small file with a few levels
3494
+ * of nested aliases (an "alias bomb") can expand into megabytes of output or
3495
+ * exhaust the heap. Counting every visited value against this budget turns
3496
+ * that into an error instead. Real frontmatter is a handful of keys; even a
3497
+ * generous skill manifest stays orders of magnitude below the limit.
3498
+ */
3499
+ const MAX_FRONTMATTER_VALUES = 1e5;
3500
+ /**
3501
+ * Upper bound on the total character count of string leaves a frontmatter
3502
+ * document may expand to.
3503
+ *
3504
+ * {@link MAX_FRONTMATTER_VALUES} bounds how many values are visited, but a
3505
+ * single long string aliased thousands of times still fits that budget while
3506
+ * the duplicated output balloons: one scalar of a few KB, chained through a
3507
+ * handful of aliases within the value budget, can multiply into a document
3508
+ * many megabytes larger than it started. Charging every visited string's
3509
+ * length against this separate budget bounds that output regardless of how
3510
+ * many aliases point at it.
3511
+ */
3512
+ const MAX_FRONTMATTER_STRING_CHARS = 4e6;
3513
+ /**
3514
+ * Upper bound on the raw character length of the `---`-delimited frontmatter
3515
+ * block itself, checked before it is ever handed to the YAML parser.
3516
+ *
3517
+ * The budgets above only bound the *parsed* document — the walk over
3518
+ * `matter()`'s output — but a complex YAML key (an array or mapping used as a
3519
+ * mapping key) is joined into a string by js-yaml while it parses, and a
3520
+ * mapping with many such keys can cost real memory before that walk ever
3521
+ * starts, or even before `matter()` returns. Capping the raw block size keeps
3522
+ * that parse-time cost bounded regardless of what the block contains. Real
3523
+ * frontmatter blocks are a few hundred bytes at most; even a large project
3524
+ * manifest stays well under this.
3525
+ */
3526
+ const MAX_FRONTMATTER_RAW_CHARS = 65536;
3527
+ /** Charge string content (a string leaf or an object key) against the character budget. */
3528
+ function chargeStringChars({ options, chars }) {
3529
+ options.budget.stringCharsRemaining -= chars;
3530
+ if (options.budget.stringCharsRemaining < 0) throw new Error(`Frontmatter's string values expand to more than ${MAX_FRONTMATTER_STRING_CHARS} characters; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3531
+ }
3532
+ function consumeBudget({ options, stringChars = 0 }) {
3533
+ options.budget.remaining -= 1;
3534
+ if (options.budget.remaining < 0) throw new Error(`Frontmatter expands to more than ${MAX_FRONTMATTER_VALUES} values; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3535
+ chargeStringChars({
3536
+ options,
3537
+ chars: stringChars
3538
+ });
3539
+ }
3540
+ /** Enter one more container level, throwing if the depth cap is exceeded. */
3541
+ function enterContainer({ options, container }) {
3542
+ options.depth += 1;
3543
+ if (options.depth > 64) throw new Error(`Frontmatter nests more than 64 levels deep; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3544
+ options.ancestors.add(container);
3545
+ }
3546
+ /** Leave a container level entered via {@link enterContainer}. */
3547
+ function leaveContainer({ options, container }) {
3548
+ options.ancestors.delete(container);
3549
+ options.depth -= 1;
3550
+ }
3551
+ /**
3552
+ * Estimate the serialized character cost of a leaf that is not a string (a
3553
+ * string leaf is charged by its own length instead).
3554
+ *
3555
+ * js-yaml's default schema resolves `!!binary` scalars to a `Uint8Array`, and
3556
+ * its dumper writes one back out as base64 — roughly 4 output characters per
3557
+ * 3 input bytes. Without this, an aliased binary blob would walk the budget
3558
+ * for free even though it can dominate the emitted document's size.
3559
+ */
3560
+ function estimateLeafChars(value) {
3561
+ if (value instanceof Uint8Array) return Math.ceil(value.byteLength / 3) * 4;
3562
+ return 0;
3563
+ }
3564
+ /**
3565
+ * Copy one parsed value, dropping nullish leaves and cyclic references.
3566
+ *
3567
+ * Every alias is still written out as an independent copy, as gray-matter's
3568
+ * default YAML engine would otherwise serialize shared references as `&ref_0`
3569
+ * anchors that simplified frontmatter parsers cannot read; the expansion is
3570
+ * bounded by {@link MAX_FRONTMATTER_VALUES} instead.
3571
+ */
3572
+ function deepCleanValue(value, options) {
3573
+ consumeBudget({
3574
+ options,
3575
+ stringChars: typeof value === "string" ? value.length : estimateLeafChars(value)
3576
+ });
3420
3577
  if (value === null || value === void 0) return;
3421
- if (Array.isArray(value)) return value.map((item) => deepRemoveNullishValue(item)).filter((item) => item !== void 0);
3422
- if (isPlainObject$1(value)) {
3423
- const result = {};
3424
- for (const [key, val] of Object.entries(value)) {
3425
- const cleaned = deepRemoveNullishValue(val);
3426
- if (cleaned !== void 0) result[key] = cleaned;
3578
+ if (typeof value === "string") return options.transformString ? options.transformString(value) : value;
3579
+ if (Array.isArray(value)) {
3580
+ if (options.ancestors.has(value)) return;
3581
+ enterContainer({
3582
+ options,
3583
+ container: value
3584
+ });
3585
+ const cleanedArray = [];
3586
+ for (const item of value) {
3587
+ const cleaned = deepCleanValue(item, options);
3588
+ if (cleaned !== void 0) cleanedArray.push(cleaned);
3427
3589
  }
3590
+ leaveContainer({
3591
+ options,
3592
+ container: value
3593
+ });
3594
+ return cleanedArray;
3595
+ }
3596
+ if (isPlainObject$1(value)) {
3597
+ if (options.ancestors.has(value)) return;
3598
+ enterContainer({
3599
+ options,
3600
+ container: value
3601
+ });
3602
+ const result = cleanOwnEntries(value, options);
3603
+ leaveContainer({
3604
+ options,
3605
+ container: value
3606
+ });
3428
3607
  return result;
3429
3608
  }
3430
3609
  return value;
3431
3610
  }
3432
- function deepRemoveNullishObject(obj) {
3433
- if (!obj || typeof obj !== "object") return {};
3611
+ /**
3612
+ * Copy the cleaned own entries of a parsed object into a fresh record.
3613
+ *
3614
+ * A YAML parser defines a `__proto__:` key as an own property, and assigning
3615
+ * it back with bracket notation would instead replace the new record's
3616
+ * prototype, whose members zod's loose object schemas then promote to real
3617
+ * keys. So a fetched skill could hide `allowed-tools` under an innocuous
3618
+ * looking `__proto__:` block. That key, `constructor` and `prototype` are
3619
+ * therefore dropped rather than copied, and cannot be used as frontmatter
3620
+ * keys.
3621
+ */
3622
+ function cleanOwnEntries(obj, options) {
3434
3623
  const result = {};
3435
3624
  for (const [key, val] of Object.entries(obj)) {
3436
- const cleaned = deepRemoveNullishValue(val);
3625
+ chargeStringChars({
3626
+ options,
3627
+ chars: key.length
3628
+ });
3629
+ const cleaned = deepCleanValue(val, options);
3630
+ if (isPrototypePollutionKey(key)) continue;
3437
3631
  if (cleaned !== void 0) result[key] = cleaned;
3438
3632
  }
3439
3633
  return result;
3440
3634
  }
3441
- function deepFlattenStringsValue(value) {
3442
- if (value === null || value === void 0) return;
3443
- if (typeof value === "string") return value.replace(/\n+/g, " ").trim();
3444
- if (Array.isArray(value)) return value.map((item) => deepFlattenStringsValue(item)).filter((item) => item !== void 0);
3445
- if (isPlainObject$1(value)) {
3446
- const result = {};
3447
- for (const [key, val] of Object.entries(value)) {
3448
- const cleaned = deepFlattenStringsValue(val);
3449
- if (cleaned !== void 0) result[key] = cleaned;
3450
- }
3451
- return result;
3452
- }
3453
- return value;
3635
+ function deepCleanObject(obj, options) {
3636
+ if (!obj || typeof obj !== "object") return {};
3637
+ return cleanOwnEntries(obj, {
3638
+ ...options,
3639
+ ancestors: new WeakSet([obj]),
3640
+ budget: {
3641
+ remaining: MAX_FRONTMATTER_VALUES,
3642
+ stringCharsRemaining: MAX_FRONTMATTER_STRING_CHARS
3643
+ },
3644
+ depth: 1
3645
+ });
3646
+ }
3647
+ /** Drop null and undefined values, recursively. */
3648
+ function deepRemoveNullishObject(obj) {
3649
+ return deepCleanObject(obj, {});
3454
3650
  }
3651
+ /** Drop nullish values and collapse every string onto a single line. */
3455
3652
  function deepFlattenStringsObject(obj) {
3456
- if (!obj || typeof obj !== "object") return {};
3457
- const result = {};
3458
- for (const [key, val] of Object.entries(obj)) {
3459
- const cleaned = deepFlattenStringsValue(val);
3460
- if (cleaned !== void 0) result[key] = cleaned;
3461
- }
3462
- return result;
3653
+ return deepCleanObject(obj, { transformString: (value) => value.replace(/\n+/g, " ").trim() });
3463
3654
  }
3464
3655
  function stringifyFrontmatter(body, frontmatter, options) {
3465
3656
  const { avoidBlockScalars = false } = options ?? {};
3466
3657
  const cleanFrontmatter = avoidBlockScalars ? deepFlattenStringsObject(frontmatter) : deepRemoveNullishObject(frontmatter);
3467
- if (avoidBlockScalars) return gray_matter.default.stringify(body, cleanFrontmatter, { engines: { yaml: {
3658
+ const file = { content: body };
3659
+ if (avoidBlockScalars) return gray_matter.default.stringify(file, cleanFrontmatter, { engines: { yaml: {
3468
3660
  parse: (input) => loadYaml(input) ?? {},
3469
3661
  stringify: (data) => (0, js_yaml.dump)(data, { lineWidth: -1 })
3470
3662
  } } });
3471
- return gray_matter.default.stringify(body, cleanFrontmatter);
3663
+ return gray_matter.default.stringify(file, cleanFrontmatter);
3472
3664
  }
3473
3665
  function parseFrontmatter(content, filePath) {
3474
3666
  let frontmatter;
3475
3667
  let body;
3476
3668
  let hasFrontmatter;
3477
3669
  try {
3670
+ const bounds = findFrontmatterBlockBounds(content);
3671
+ if (bounds && bounds.blockEnd - bounds.blockStart > 65536) throw new Error(`Frontmatter block is larger than ${MAX_FRONTMATTER_RAW_CHARS} characters; refusing to parse it (a complex YAML key can cost memory while parsing, before any post-parse budget applies)`);
3478
3672
  const result = (0, gray_matter.default)(content, {});
3479
- frontmatter = result.data;
3673
+ frontmatter = deepRemoveNullishObject(result.data);
3480
3674
  body = result.content;
3481
3675
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
3482
3676
  } catch (error) {
@@ -3484,7 +3678,7 @@ function parseFrontmatter(content, filePath) {
3484
3678
  throw error;
3485
3679
  }
3486
3680
  return {
3487
- frontmatter: deepRemoveNullishObject(frontmatter),
3681
+ frontmatter,
3488
3682
  body,
3489
3683
  hasFrontmatter
3490
3684
  };
@@ -3533,17 +3727,34 @@ function repairFrontmatterLine(line) {
3533
3727
  };
3534
3728
  }
3535
3729
  /**
3536
- * Quote the unquoted scalars that make a frontmatter block unparseable, or
3537
- * return `undefined` when there is nothing to repair. Only the frontmatter
3538
- * block is rewritten; the body is passed through untouched.
3730
+ * Locate a raw `---`-delimited frontmatter block's bounds within `content`,
3731
+ * without parsing it. Shared by the size guard in {@link parseFrontmatter} and
3732
+ * the YAML repair pass below, so both agree on exactly what gray-matter would
3733
+ * treat as the block: gray-matter ends it at the first `\n---`, with no
3734
+ * requirement that the delimiter be alone on its line, so a stricter pattern
3735
+ * here would run past gray-matter's delimiter and act on text that is really
3736
+ * the body.
3539
3737
  */
3540
- function repairMalformedFrontmatterYaml(content) {
3738
+ function findFrontmatterBlockBounds(content) {
3541
3739
  const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
3542
3740
  if (!opening) return;
3543
3741
  const blockStart = opening[0].length;
3544
3742
  const closing = /\r?\n---/.exec(content.slice(blockStart));
3545
3743
  if (!closing) return;
3546
- const blockEnd = blockStart + closing.index;
3744
+ return {
3745
+ blockStart,
3746
+ blockEnd: blockStart + closing.index
3747
+ };
3748
+ }
3749
+ /**
3750
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
3751
+ * return `undefined` when there is nothing to repair. Only the frontmatter
3752
+ * block is rewritten; the body is passed through untouched.
3753
+ */
3754
+ function repairMalformedFrontmatterYaml(content) {
3755
+ const bounds = findFrontmatterBlockBounds(content);
3756
+ if (!bounds) return;
3757
+ const { blockStart, blockEnd } = bounds;
3547
3758
  const block = content.slice(blockStart, blockEnd);
3548
3759
  const repairedLines = block.split("\n").map(repairFrontmatterLine);
3549
3760
  const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
@@ -5222,42 +5433,6 @@ const CANONICAL_TO_GROKCLI_EVENT_NAMES = {
5222
5433
  */
5223
5434
  const GROKCLI_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_GROKCLI_EVENT_NAMES).map(([k, v]) => [v, k]));
5224
5435
  //#endregion
5225
- //#region src/utils/prototype-pollution.ts
5226
- /**
5227
- * Keys that, if walked into when constructing or merging objects from
5228
- * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
5229
- * chain) and propagate state to every other object in the runtime. Any code
5230
- * that copies arbitrary user-supplied keys into a fresh object — frontmatter
5231
- * parsing, MCP config conversion, settings round-trip — should skip these.
5232
- */
5233
- const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
5234
- "__proto__",
5235
- "constructor",
5236
- "prototype"
5237
- ]);
5238
- function isPrototypePollutionKey(key) {
5239
- return PROTOTYPE_POLLUTION_KEYS.has(key);
5240
- }
5241
- /**
5242
- * Returns a shallow copy of a record's own entries with every
5243
- * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
5244
- *
5245
- * Use when copying a nested, user-supplied string map — an MCP server's `env`
5246
- * or `headers` table — into freshly generated config. Carrying such a map by
5247
- * reference, or re-assigning its keys via bracket notation, would let a literal
5248
- * `__proto__` key ride along (and re-assigning it would mutate the target's
5249
- * prototype). Walking the entries through this helper severs that path while
5250
- * preserving every legitimate key.
5251
- */
5252
- function omitPrototypePollutionKeys(record) {
5253
- const sanitized = {};
5254
- for (const [key, value] of Object.entries(record)) {
5255
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
5256
- sanitized[key] = value;
5257
- }
5258
- return sanitized;
5259
- }
5260
- //#endregion
5261
5436
  //#region src/utils/jsonc.ts
5262
5437
  /**
5263
5438
  * Rebuild the parsed value from its own enumerable entries, dropping
@@ -6008,6 +6183,17 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6008
6183
  const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
6009
6184
  throw new RulesyncSourceNotFoundError(`No ${(0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath)} found.`);
6010
6185
  }
6186
+ /**
6187
+ * Return one server exactly as authored, before `getMcpServers()` strips
6188
+ * rulesync- and tool-specific fields. Keep this lookup here so every target
6189
+ * that re-merges one of those fields shares the same own-property and
6190
+ * prototype-pollution guards.
6191
+ */
6192
+ getRawMcpServer(name) {
6193
+ if (isPrototypePollutionKey(name)) return void 0;
6194
+ const mcpServers = isRecord$1(this.json) ? this.json.mcpServers : void 0;
6195
+ return isRecord$1(mcpServers) && Object.hasOwn(mcpServers, name) ? mcpServers[name] : void 0;
6196
+ }
6011
6197
  getMcpServers() {
6012
6198
  const mcpServers = this.json.mcpServers ?? {};
6013
6199
  const entries = Object.entries(mcpServers);
@@ -7634,6 +7820,11 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7634
7820
  globs: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
7635
7821
  agentsmd: zod_mini.z.optional(zod_mini.z.looseObject({ subprojectPath: zod_mini.z.optional(zod_mini.z.string()) })),
7636
7822
  claudecode: zod_mini.z.optional(zod_mini.z.looseObject({ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) })),
7823
+ codebuddy: zod_mini.z.optional(zod_mini.z.looseObject({
7824
+ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
7825
+ alwaysApply: zod_mini.z.optional(zod_mini.z.boolean()),
7826
+ description: zod_mini.z.optional(zod_mini.z.string())
7827
+ })),
7637
7828
  cursor: zod_mini.z.optional(zod_mini.z.looseObject({
7638
7829
  alwaysApply: zod_mini.z.optional(zod_mini.z.boolean()),
7639
7830
  description: zod_mini.z.optional(zod_mini.z.string()),
@@ -7675,7 +7866,8 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7675
7866
  name: zod_mini.z.optional(zod_mini.z.string()),
7676
7867
  extends: zod_mini.z.optional(zod_mini.z.string()),
7677
7868
  facet: zod_mini.z.optional(zod_mini.z.enum(["policies", "output-contracts"]))
7678
- }))
7869
+ })),
7870
+ factorydroid: zod_mini.z.optional(zod_mini.z.looseObject({ channel: zod_mini.z.optional(zod_mini.z.enum(["design"])) }))
7679
7871
  });
7680
7872
  /**
7681
7873
  * The `agentsmd.subprojectPath` every consumer should act on, resolved once so
@@ -8790,15 +8982,15 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8790
8982
  })),
8791
8983
  opencode: zod_mini.z.optional(zod_mini.z.looseObject({
8792
8984
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8793
- license: zod_mini.z.optional(zod_mini.z.string()),
8794
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8795
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8985
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
8986
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
8987
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8796
8988
  })),
8797
8989
  kilo: zod_mini.z.optional(zod_mini.z.looseObject({
8798
8990
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8799
- license: zod_mini.z.optional(zod_mini.z.string()),
8800
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8801
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8991
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
8992
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
8993
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8802
8994
  })),
8803
8995
  kiro: zod_mini.z.optional(zod_mini.z.looseObject({
8804
8996
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -8807,9 +8999,9 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8807
8999
  })),
8808
9000
  deepagents: zod_mini.z.optional(zod_mini.z.looseObject({
8809
9001
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8810
- license: zod_mini.z.optional(zod_mini.z.string()),
8811
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8812
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
9002
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
9003
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
9004
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8813
9005
  })),
8814
9006
  copilot: zod_mini.z.optional(zod_mini.z.looseObject({
8815
9007
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -8916,6 +9108,13 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8916
9108
  takt: zod_mini.z.optional(zod_mini.z.looseObject({
8917
9109
  name: zod_mini.z.optional(zod_mini.z.string()),
8918
9110
  extends: zod_mini.z.optional(zod_mini.z.string())
9111
+ })),
9112
+ crush: zod_mini.z.optional(zod_mini.z.looseObject({
9113
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
9114
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
9115
+ license: zod_mini.z.optional(zod_mini.z.string()),
9116
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
9117
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8919
9118
  }))
8920
9119
  });
8921
9120
  /**
@@ -9141,7 +9340,7 @@ async function getLocalSkillDirNames(sourceTree) {
9141
9340
  *
9142
9341
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
9143
9342
  * default that applies to every tool supporting the flag (claudecode, copilot,
9144
- * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9343
+ * copilotcli, crush, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9145
9344
  * default with a per-target value. A defined section value (including `false`)
9146
9345
  * always wins over the root default.
9147
9346
  *
@@ -9160,7 +9359,7 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
9160
9359
  *
9161
9360
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
9162
9361
  * that applies to every tool supporting the flag (claudecode, copilot,
9163
- * copilotcli, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9362
+ * copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9164
9363
  * per-target value. A defined section value (including `false`) always wins
9165
9364
  * over the root default.
9166
9365
  *
@@ -9625,8 +9824,8 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
9625
9824
  * This only deletes files that are no longer in the rulesync source, not files that will be overwritten.
9626
9825
  */
9627
9826
  async removeOrphanAiFiles(existingFiles, generatedFiles) {
9628
- const generatedPaths = new Set(generatedFiles.map((f) => f.getFilePath()));
9629
- const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(f.getFilePath()));
9827
+ const generatedPaths = new Set(generatedFiles.map((f) => caseFoldIdentity(f.getFilePath())));
9828
+ const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())));
9630
9829
  for (const aiFile of orphanFiles) {
9631
9830
  const filePath = aiFile.getFilePath();
9632
9831
  const loggedPath = stripControlCharacters(filePath);
@@ -10753,6 +10952,15 @@ const FACTORYDROID_COMMANDS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "co
10753
10952
  const FACTORYDROID_SKILLS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "skills");
10754
10953
  const FACTORYDROID_DROIDS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "droids");
10755
10954
  const FACTORYDROID_RULE_FILE_NAME = "AGENTS.md";
10955
+ /**
10956
+ * Factory Droid's design-guidelines instruction file: "Always-on design-system,
10957
+ * UX, visual, and interaction guidance", loaded separately from `AGENTS.md`'s
10958
+ * coding guidelines. Project scope only — Factory's docs describe root and
10959
+ * nested `DESIGN.md` files like `AGENTS.md`, but document no personal/global
10960
+ * home-directory equivalent.
10961
+ * @see https://docs.factory.ai/cli/configuration/agents-md
10962
+ */
10963
+ const FACTORYDROID_DESIGN_FILE_NAME = "DESIGN.md";
10756
10964
  const FACTORYDROID_MCP_FILE_NAME = "mcp.json";
10757
10965
  const FACTORYDROID_SETTINGS_FILE_NAME = "settings.json";
10758
10966
  const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
@@ -15652,6 +15860,10 @@ function toAllowedToolsArray(value) {
15652
15860
  * The spec types `compatibility` as a free-form string. An object from a legacy
15653
15861
  * rulesync input is flattened to `key: value` pairs instead of being emitted as
15654
15862
  * a YAML mapping, which conformant clients reject.
15863
+ *
15864
+ * Exported for `CrushSkill`, which requires the same bare-string shape (Crush's
15865
+ * Go struct types `Compatibility` as a plain `string`) and reuses this
15866
+ * implementation rather than maintaining a second, divergent copy.
15655
15867
  */
15656
15868
  function toCompatibilityString(value) {
15657
15869
  if (typeof value === "string") return value;
@@ -15660,6 +15872,10 @@ function toCompatibilityString(value) {
15660
15872
  /**
15661
15873
  * The spec types `metadata` as "a map from string keys to string values", so
15662
15874
  * non-string values (e.g. a YAML number `version: 1`) are stringified.
15875
+ *
15876
+ * Exported for `CrushSkill`, which requires the same `map[string]string`
15877
+ * shape (Crush's Go struct types `Metadata` that way) and reuses this
15878
+ * implementation rather than maintaining a second, divergent copy.
15663
15879
  */
15664
15880
  function toStringMetadata(metadata) {
15665
15881
  return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, stringifyValue(value)]));
@@ -18670,7 +18886,13 @@ var CommandsProcessor = class extends FeatureProcessor {
18670
18886
  if (!matchByBasename || flatOnly && (0, node_path.dirname)(key) !== ".") return [key];
18671
18887
  return [key, (0, node_path.basename)(key)];
18672
18888
  };
18673
- const seen = new Set(toolCommands.flatMap((command) => keysOf(command)));
18889
+ const claimedKeys = new ClaimedIdentities();
18890
+ const primarySource = paths.relativeDirPath;
18891
+ const secondarySource = "a secondary source";
18892
+ for (const command of toolCommands) for (const candidate of keysOf(command)) claimedKeys.claim({
18893
+ identity: candidate,
18894
+ source: primarySource
18895
+ });
18674
18896
  const additionalCommands = await factory.class.loadAdditionalImportFiles({
18675
18897
  outputRoot: this.outputRoot,
18676
18898
  global: this.global,
@@ -18678,11 +18900,26 @@ var CommandsProcessor = class extends FeatureProcessor {
18678
18900
  });
18679
18901
  for (const command of additionalCommands) {
18680
18902
  const key = command.getRelativeFilePath();
18681
- if (keysOf(command, true).some((candidate) => seen.has(candidate))) {
18682
- this.logger.warn(`Duplicate ${this.toolTarget} command "${key}" from a secondary source; keeping the one already loaded.`);
18903
+ const collision = [...new Set(keysOf(command, true))].map((candidate) => {
18904
+ const claimed = claimedKeys.claim({
18905
+ identity: candidate,
18906
+ source: secondarySource
18907
+ });
18908
+ return claimed === null ? void 0 : {
18909
+ candidate,
18910
+ claimed
18911
+ };
18912
+ }).find((hit) => hit !== void 0);
18913
+ if (collision) {
18914
+ const { candidate, claimed } = collision;
18915
+ if (claimed.spelling === candidate) this.logger.warn(`Duplicate ${this.toolTarget} command "${stripControlCharacters(key)}" from ${secondarySource}; keeping the one already loaded.`);
18916
+ else this.logger.warn(`Case-insensitive ${this.toolTarget} command collision: "${stripControlCharacters(claimed.spelling)}" and "${stripControlCharacters(candidate)}" resolve to the same command file. Keeping "${stripControlCharacters(claimed.spelling)}" from ${claimed.source === secondarySource ? "earlier in the same source" : `the higher-precedence ${claimed.source}`} and ignoring "${stripControlCharacters(key)}" from ${secondarySource}, which is not imported.`);
18683
18917
  continue;
18684
18918
  }
18685
- for (const candidate of keysOf(command)) seen.add(candidate);
18919
+ for (const candidate of keysOf(command)) claimedKeys.claim({
18920
+ identity: candidate,
18921
+ source: secondarySource
18922
+ });
18686
18923
  toolCommands.push(command);
18687
18924
  }
18688
18925
  }
@@ -18972,6 +19209,21 @@ var AmpHooks = class AmpHooks extends ToolHooks {
18972
19209
  }
18973
19210
  };
18974
19211
  //#endregion
19212
+ //#region src/utils/own-lookup.ts
19213
+ /**
19214
+ * Read a key from a plain string map without walking its prototype chain.
19215
+ *
19216
+ * A bracket read on an object literal resolves inherited members too, so a
19217
+ * user-supplied key such as `toString` or `constructor` "succeeds" with an
19218
+ * `Object.prototype` function instead of falling through to the caller's
19219
+ * `?? fallback`. Hook adapters translate native event names this way from
19220
+ * `Object.entries()` over a config file, so route the read through here to keep
19221
+ * the fallback honest: only a key the map itself defines yields a value.
19222
+ */
19223
+ function lookupOwn({ record, key }) {
19224
+ return Object.hasOwn(record, key) ? record[key] : void 0;
19225
+ }
19226
+ //#endregion
18975
19227
  //#region src/utils/object.ts
18976
19228
  /**
18977
19229
  * Return a shallow copy of `obj` keeping only the entries whose value is
@@ -19356,7 +19608,10 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
19356
19608
  const warn = warnOnce(logger);
19357
19609
  const result = {};
19358
19610
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
19359
- const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
19611
+ const toolEventName = lookupOwn({
19612
+ record: converterConfig.canonicalToToolEventNames,
19613
+ key: eventName
19614
+ }) ?? eventName;
19360
19615
  const byMatcher = groupDefinitionsByMatcher({
19361
19616
  definitions,
19362
19617
  converterConfig
@@ -19784,7 +20039,10 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
19784
20039
  const warn = warnOnce(logger);
19785
20040
  const canonical = {};
19786
20041
  for (const [toolEventName, matcherEntries] of Object.entries(hooks)) {
19787
- const eventName = converterConfig.toolToCanonicalEventNames[toolEventName] ?? toolEventName;
20042
+ const eventName = lookupOwn({
20043
+ record: converterConfig.toolToCanonicalEventNames,
20044
+ key: toolEventName
20045
+ }) ?? toolEventName;
19788
20046
  if (!Array.isArray(matcherEntries)) continue;
19789
20047
  const defs = [];
19790
20048
  for (const rawEntry of matcherEntries) {
@@ -19840,7 +20098,10 @@ function flattenAntigravityHooks(parsed) {
19840
20098
  const flat = {};
19841
20099
  const addEvent = (event, entries) => {
19842
20100
  if (isPrototypePollutionKey(event) || !Array.isArray(entries)) return;
19843
- const existing = Object.hasOwn(flat, event) ? flat[event] : void 0;
20101
+ const existing = lookupOwn({
20102
+ record: flat,
20103
+ key: event
20104
+ });
19844
20105
  flat[event] = existing ? [...existing, ...entries] : [...entries];
19845
20106
  };
19846
20107
  for (const [key, value] of Object.entries(parsed)) if (Array.isArray(value)) addEvent(key, value);
@@ -20227,6 +20488,16 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20227
20488
  const paths = AugmentcodeHooks.getSettablePaths({ global });
20228
20489
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
20229
20490
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
20491
+ let existingHooks = {};
20492
+ try {
20493
+ const parsed = JSON.parse(existingContent);
20494
+ const candidate = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.hooks : void 0;
20495
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) existingHooks = candidate;
20496
+ } catch {
20497
+ existingHooks = {};
20498
+ }
20499
+ const nativeEventKeys = new Set(Object.values(CANONICAL_TO_AUGMENTCODE_EVENT_NAMES));
20500
+ const preservedHooks = Object.fromEntries(Object.entries(existingHooks).filter(([key]) => !nativeEventKeys.has(key)));
20230
20501
  const config = rulesyncHooks.getJson();
20231
20502
  const augmentHooks = canonicalToToolHooks({
20232
20503
  config,
@@ -20238,7 +20509,10 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20238
20509
  fileKey: sharedConfigFileKey(paths),
20239
20510
  feature: "hooks",
20240
20511
  existingContent,
20241
- patch: { hooks: augmentHooks },
20512
+ patch: { hooks: {
20513
+ ...preservedHooks,
20514
+ ...augmentHooks
20515
+ } },
20242
20516
  filePath
20243
20517
  });
20244
20518
  return new AugmentcodeHooks({
@@ -20996,7 +21270,10 @@ function canonicalToCopilotHooks(config) {
20996
21270
  };
20997
21271
  const copilot = {};
20998
21272
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
20999
- const copilotEventName = CANONICAL_TO_COPILOT_EVENT_NAMES[eventName] ?? eventName;
21273
+ const copilotEventName = lookupOwn({
21274
+ record: CANONICAL_TO_COPILOT_EVENT_NAMES,
21275
+ key: eventName
21276
+ }) ?? eventName;
21000
21277
  const entries = [];
21001
21278
  for (const def of definitions) {
21002
21279
  const hookType = def.type ?? "command";
@@ -21073,7 +21350,10 @@ function copilotHooksToCanonical(copilotHooks, logger) {
21073
21350
  if (copilotHooks === null || copilotHooks === void 0 || typeof copilotHooks !== "object") return {};
21074
21351
  const canonical = {};
21075
21352
  for (const [copilotEventName, hookEntries] of Object.entries(copilotHooks)) {
21076
- const eventName = COPILOT_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21353
+ const eventName = lookupOwn({
21354
+ record: COPILOT_TO_CANONICAL_EVENT_NAMES,
21355
+ key: copilotEventName
21356
+ }) ?? copilotEventName;
21077
21357
  if (!Array.isArray(hookEntries)) continue;
21078
21358
  const defs = [];
21079
21359
  for (const rawEntry of hookEntries) {
@@ -21343,7 +21623,10 @@ function canonicalToCopilotCliHooks(config, logger) {
21343
21623
  };
21344
21624
  const out = {};
21345
21625
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
21346
- const copilotEventName = CANONICAL_TO_COPILOTCLI_EVENT_NAMES[eventName] ?? eventName;
21626
+ const copilotEventName = lookupOwn({
21627
+ record: CANONICAL_TO_COPILOTCLI_EVENT_NAMES,
21628
+ key: eventName
21629
+ }) ?? eventName;
21347
21630
  const entries = buildCopilotCliEntriesForEvent({
21348
21631
  eventName,
21349
21632
  definitions,
@@ -21400,7 +21683,10 @@ function copilotCliHooksToCanonical(rawHooks, logger) {
21400
21683
  if (rawHooks === null || rawHooks === void 0 || typeof rawHooks !== "object") return {};
21401
21684
  const canonical = {};
21402
21685
  for (const [copilotEventName, hookEntries] of Object.entries(rawHooks)) {
21403
- const eventName = COPILOTCLI_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21686
+ const eventName = lookupOwn({
21687
+ record: COPILOTCLI_TO_CANONICAL_EVENT_NAMES,
21688
+ key: copilotEventName
21689
+ }) ?? copilotEventName;
21404
21690
  if (!Array.isArray(hookEntries)) continue;
21405
21691
  const defs = [];
21406
21692
  for (const rawEntry of hookEntries) {
@@ -21552,7 +21838,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21552
21838
  const mappedHooks = {};
21553
21839
  const cursorSupportedTypes = /* @__PURE__ */ new Set(["command", "prompt"]);
21554
21840
  for (const [eventName, defs] of Object.entries(mergedHooks)) {
21555
- const cursorEventName = CANONICAL_TO_CURSOR_EVENT_NAMES[eventName] ?? eventName;
21841
+ const cursorEventName = lookupOwn({
21842
+ record: CANONICAL_TO_CURSOR_EVENT_NAMES,
21843
+ key: eventName
21844
+ }) ?? eventName;
21556
21845
  const mappedDefs = defs.filter((def) => cursorSupportedTypes.has(def.type ?? "command")).map((def) => ({
21557
21846
  ...def.type !== void 0 && def.type !== null && { type: def.type },
21558
21847
  ...def.command !== void 0 && def.command !== null && { command: def.command },
@@ -21585,7 +21874,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21585
21874
  const cursorHooks = parsed.hooks ?? {};
21586
21875
  const canonicalHooks = {};
21587
21876
  for (const [cursorEventName, defs] of Object.entries(cursorHooks)) {
21588
- const eventName = CURSOR_TO_CANONICAL_EVENT_NAMES[cursorEventName] ?? cursorEventName;
21877
+ const eventName = lookupOwn({
21878
+ record: CURSOR_TO_CANONICAL_EVENT_NAMES,
21879
+ key: cursorEventName
21880
+ }) ?? cursorEventName;
21589
21881
  canonicalHooks[eventName] = defs;
21590
21882
  }
21591
21883
  const version = parsed.version ?? 1;
@@ -21655,7 +21947,10 @@ function canonicalToDeepagentsHooks(config) {
21655
21947
  const hooks = {};
21656
21948
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
21657
21949
  if (!supported.has(canonicalEvent)) continue;
21658
- const deepagentsEvent = CANONICAL_TO_DEEPAGENTS_EVENT_NAMES[canonicalEvent];
21950
+ const deepagentsEvent = lookupOwn({
21951
+ record: CANONICAL_TO_DEEPAGENTS_EVENT_NAMES,
21952
+ key: canonicalEvent
21953
+ });
21659
21954
  if (!deepagentsEvent) continue;
21660
21955
  for (const def of definitions) {
21661
21956
  if ((def.type ?? "command") !== "command") continue;
@@ -21684,7 +21979,10 @@ function canonicalToDeepagentsHooks(config) {
21684
21979
  function deepagentsToCanonicalHooks(hooks) {
21685
21980
  const canonical = {};
21686
21981
  for (const [deepagentsEvent, groups] of Object.entries(hooks)) {
21687
- const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
21982
+ const canonicalEvent = lookupOwn({
21983
+ record: DEEPAGENTS_TO_CANONICAL_EVENT_NAMES,
21984
+ key: deepagentsEvent
21985
+ });
21688
21986
  if (!canonicalEvent || !Array.isArray(groups)) continue;
21689
21987
  for (const group of groups) {
21690
21988
  if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
@@ -21717,7 +22015,10 @@ function deepagentsLegacyToCanonicalHooks(entries) {
21717
22015
  const command = argv.length === 3 && argv[0] === "bash" && argv[1] === "-c" ? String(argv[2] ?? "") : argv.join(" ");
21718
22016
  const events = Array.isArray(entry.events) ? entry.events : [];
21719
22017
  for (const legacyEvent of events) {
21720
- const canonicalEvent = typeof legacyEvent === "string" ? DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES[legacyEvent] : void 0;
22018
+ const canonicalEvent = typeof legacyEvent === "string" ? lookupOwn({
22019
+ record: DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES,
22020
+ key: legacyEvent
22021
+ }) : void 0;
21721
22022
  if (!canonicalEvent) continue;
21722
22023
  (canonical[canonicalEvent] ??= []).push({
21723
22024
  type: "command",
@@ -22438,7 +22739,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22438
22739
  const result = {};
22439
22740
  for (const [canonicalEvent, definitions] of Object.entries(config.hooks)) {
22440
22741
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22441
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22742
+ const nativeEvent = lookupOwn({
22743
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22744
+ key: canonicalEvent
22745
+ });
22442
22746
  if (nativeEvent) setHermesHookEntries({
22443
22747
  result,
22444
22748
  event: nativeEvent,
@@ -22449,7 +22753,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22449
22753
  }
22450
22754
  for (const [canonicalEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
22451
22755
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22452
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22756
+ const nativeEvent = lookupOwn({
22757
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22758
+ key: canonicalEvent
22759
+ });
22453
22760
  if (nativeEvent) setHermesHookEntries({
22454
22761
  result,
22455
22762
  event: nativeEvent,
@@ -22515,7 +22822,10 @@ function hermesHooksToCanonical(hooks) {
22515
22822
  for (const [nativeEvent, entries] of Object.entries(hooks)) {
22516
22823
  if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) continue;
22517
22824
  if (!isHermesHookEventEntry(nativeEvent, entries)) continue;
22518
- const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;
22825
+ const rulesyncEvent = lookupOwn({
22826
+ record: HERMESAGENT_TO_CANONICAL_EVENT_NAMES,
22827
+ key: nativeEvent
22828
+ }) ?? nativeEvent;
22519
22829
  const defs = entries.map((raw) => hermesEntryToDefinition({
22520
22830
  nativeEvent,
22521
22831
  raw
@@ -23152,7 +23462,10 @@ function canonicalToKimiCodeHooks({ config, toolOverrideHooks, trustedDirectory,
23152
23462
  const result = [];
23153
23463
  const nativeEvents = new Set(KIMI_CODE_NATIVE_HOOK_EVENTS);
23154
23464
  for (const [event, definitions] of Object.entries(buildEffectiveHooks(config, toolOverrideHooks))) {
23155
- const nativeEvent = CANONICAL_TO_KIMI_CODE_EVENT_NAMES[event] ?? event;
23465
+ const nativeEvent = lookupOwn({
23466
+ record: CANONICAL_TO_KIMI_CODE_EVENT_NAMES,
23467
+ key: event
23468
+ }) ?? event;
23156
23469
  if (!nativeEvents.has(nativeEvent)) {
23157
23470
  logger?.warn(`Kimi Code hooks: skipping unsupported event "${event}".`);
23158
23471
  continue;
@@ -23186,14 +23499,22 @@ function kimiCodeHooksToCanonical(hooks) {
23186
23499
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
23187
23500
  const entry = raw;
23188
23501
  if (typeof entry.event !== "string" || typeof entry.command !== "string") continue;
23189
- const event = KIMI_CODE_TO_CANONICAL_EVENT_NAMES[entry.event] ?? entry.event;
23502
+ const event = lookupOwn({
23503
+ record: KIMI_CODE_TO_CANONICAL_EVENT_NAMES,
23504
+ key: entry.event
23505
+ }) ?? entry.event;
23190
23506
  const definition = {
23191
23507
  type: "command",
23192
23508
  command: stripTrustedDirectoryWrapper(entry.command),
23193
23509
  ...typeof entry.matcher === "string" && { matcher: entry.matcher },
23194
23510
  ...typeof entry.timeout === "number" && { timeout: entry.timeout }
23195
23511
  };
23196
- (result[event] ??= []).push(definition);
23512
+ const list = lookupOwn({
23513
+ record: result,
23514
+ key: event
23515
+ }) ?? [];
23516
+ list.push(definition);
23517
+ result[event] = list;
23197
23518
  }
23198
23519
  return result;
23199
23520
  }
@@ -23396,7 +23717,13 @@ function canonicalToKiroIdeHooks(config) {
23396
23717
  };
23397
23718
  const entries = [];
23398
23719
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23399
- const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES[eventName] ?? eventName;
23720
+ const trigger = lookupOwn({
23721
+ record: CANONICAL_TO_KIRO_IDE_EVENT_NAMES,
23722
+ key: eventName
23723
+ }) ?? lookupOwn({
23724
+ record: KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES,
23725
+ key: eventName
23726
+ }) ?? eventName;
23400
23727
  entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
23401
23728
  }
23402
23729
  return entries;
@@ -23405,7 +23732,10 @@ function kiroIdeHooksToCanonical(entries) {
23405
23732
  const canonical = {};
23406
23733
  for (const entry of entries) {
23407
23734
  if (entry.trigger === void 0 || entry.action === void 0) continue;
23408
- const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
23735
+ const eventName = lookupOwn({
23736
+ record: KIRO_IDE_TO_CANONICAL_EVENT_NAMES,
23737
+ key: entry.trigger
23738
+ }) ?? entry.trigger;
23409
23739
  if (isPrototypePollutionKey(eventName)) continue;
23410
23740
  const def = {};
23411
23741
  if (entry.action.type === "command") {
@@ -23422,7 +23752,12 @@ function kiroIdeHooksToCanonical(entries) {
23422
23752
  if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
23423
23753
  if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
23424
23754
  if (entry.enabled === false) def.enabled = false;
23425
- (canonical[eventName] ??= []).push(def);
23755
+ const list = lookupOwn({
23756
+ record: canonical,
23757
+ key: eventName
23758
+ }) ?? [];
23759
+ list.push(def);
23760
+ canonical[eventName] = list;
23426
23761
  }
23427
23762
  return canonical;
23428
23763
  }
@@ -23603,7 +23938,10 @@ function canonicalToKiroHooks({ config, logger }) {
23603
23938
  };
23604
23939
  const kiro = {};
23605
23940
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23606
- const kiroEventName = CANONICAL_TO_KIRO_EVENT_NAMES[eventName] ?? eventName;
23941
+ const kiroEventName = lookupOwn({
23942
+ record: CANONICAL_TO_KIRO_EVENT_NAMES,
23943
+ key: eventName
23944
+ }) ?? eventName;
23607
23945
  const entries = buildKiroEntriesForEvent(definitions);
23608
23946
  if (entries.length > 0) if (kiro[kiroEventName]) kiro[kiroEventName].push(...entries);
23609
23947
  else kiro[kiroEventName] = entries;
@@ -23634,7 +23972,10 @@ function kiroHooksToCanonical(kiroHooks) {
23634
23972
  if (kiroHooks === null || kiroHooks === void 0 || typeof kiroHooks !== "object") return {};
23635
23973
  const canonical = {};
23636
23974
  for (const [kiroEventName, entries] of Object.entries(kiroHooks)) {
23637
- const eventName = KIRO_TO_CANONICAL_EVENT_NAMES[kiroEventName] ?? kiroEventName;
23975
+ const eventName = lookupOwn({
23976
+ record: KIRO_TO_CANONICAL_EVENT_NAMES,
23977
+ key: kiroEventName
23978
+ }) ?? kiroEventName;
23638
23979
  if (!Array.isArray(entries)) continue;
23639
23980
  const defs = [];
23640
23981
  for (const rawEntry of entries) {
@@ -24193,7 +24534,10 @@ function canonicalToQwencodeHooks(config, logger) {
24193
24534
  ]);
24194
24535
  const qwencode = {};
24195
24536
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
24196
- const qwencodeEventName = CANONICAL_TO_QWENCODE_EVENT_NAMES[eventName] ?? eventName;
24537
+ const qwencodeEventName = lookupOwn({
24538
+ record: CANONICAL_TO_QWENCODE_EVENT_NAMES,
24539
+ key: eventName
24540
+ }) ?? eventName;
24197
24541
  const byMatcher = /* @__PURE__ */ new Map();
24198
24542
  for (const def of definitions) {
24199
24543
  if (!qwencodeSupportedTypes.has(def.type ?? "command")) continue;
@@ -24298,7 +24642,10 @@ function qwencodeHooksToCanonical(qwencodeHooks) {
24298
24642
  if (qwencodeHooks === null || qwencodeHooks === void 0 || typeof qwencodeHooks !== "object") return {};
24299
24643
  const canonical = {};
24300
24644
  for (const [qwencodeEventName, matcherEntries] of Object.entries(qwencodeHooks)) {
24301
- const eventName = QWENCODE_TO_CANONICAL_EVENT_NAMES[qwencodeEventName] ?? qwencodeEventName;
24645
+ const eventName = lookupOwn({
24646
+ record: QWENCODE_TO_CANONICAL_EVENT_NAMES,
24647
+ key: qwencodeEventName
24648
+ }) ?? qwencodeEventName;
24302
24649
  if (!Array.isArray(matcherEntries)) continue;
24303
24650
  const defs = [];
24304
24651
  for (const rawEntry of matcherEntries) {
@@ -24413,7 +24760,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24413
24760
  const result = {};
24414
24761
  for (const [event, defs] of Object.entries(effectiveHooks)) {
24415
24762
  if (!SUPPORTED_REASONIX_EVENTS.has(event)) continue;
24416
- const reasonixEvent = CANONICAL_TO_REASONIX_EVENT_NAMES[event] ?? event;
24763
+ const reasonixEvent = lookupOwn({
24764
+ record: CANONICAL_TO_REASONIX_EVENT_NAMES,
24765
+ key: event
24766
+ }) ?? event;
24417
24767
  const isMatcherEvent = REASONIX_MATCHER_EVENTS.has(reasonixEvent);
24418
24768
  const entries = [];
24419
24769
  for (const def of defs) {
@@ -24426,7 +24776,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24426
24776
  if (typeof def.timeout === "number") entry.timeout = Math.round(def.timeout * 1e3);
24427
24777
  entries.push(entry);
24428
24778
  }
24429
- if (entries.length > 0) result[reasonixEvent] = [...result[reasonixEvent] ?? [], ...entries];
24779
+ if (entries.length > 0) result[reasonixEvent] = [...lookupOwn({
24780
+ record: result,
24781
+ key: reasonixEvent
24782
+ }) ?? [], ...entries];
24430
24783
  }
24431
24784
  return result;
24432
24785
  }
@@ -24439,7 +24792,10 @@ function reasonixHooksToCanonical(hooks) {
24439
24792
  if (hooks === null || hooks === void 0 || typeof hooks !== "object" || Array.isArray(hooks)) return canonical;
24440
24793
  for (const [reasonixEvent, rawEntries] of Object.entries(hooks)) {
24441
24794
  if (!Array.isArray(rawEntries)) continue;
24442
- const canonicalEvent = REASONIX_TO_CANONICAL_EVENT_NAMES[reasonixEvent] ?? reasonixEvent;
24795
+ const canonicalEvent = lookupOwn({
24796
+ record: REASONIX_TO_CANONICAL_EVENT_NAMES,
24797
+ key: reasonixEvent
24798
+ }) ?? reasonixEvent;
24443
24799
  const defs = [];
24444
24800
  for (const rawEntry of rawEntries) {
24445
24801
  if (rawEntry === null || typeof rawEntry !== "object" || Array.isArray(rawEntry)) continue;
@@ -24454,7 +24810,10 @@ function reasonixHooksToCanonical(hooks) {
24454
24810
  if (typeof entry.timeout === "number") def.timeout = entry.timeout / 1e3;
24455
24811
  defs.push(def);
24456
24812
  }
24457
- if (defs.length > 0) canonical[canonicalEvent] = [...canonical[canonicalEvent] ?? [], ...defs];
24813
+ if (defs.length > 0) canonical[canonicalEvent] = [...lookupOwn({
24814
+ record: canonical,
24815
+ key: canonicalEvent
24816
+ }) ?? [], ...defs];
24458
24817
  }
24459
24818
  return canonical;
24460
24819
  }
@@ -24582,7 +24941,10 @@ function canonicalToVibeHooks(config, toolOverride) {
24582
24941
  const hooks = [];
24583
24942
  for (const [event, defs] of Object.entries(effective)) {
24584
24943
  if (!SUPPORTED_VIBE_EVENTS.has(event)) continue;
24585
- const vibeEvent = CANONICAL_TO_VIBE_EVENT_NAMES[event] ?? event;
24944
+ const vibeEvent = lookupOwn({
24945
+ record: CANONICAL_TO_VIBE_EVENT_NAMES,
24946
+ key: event
24947
+ }) ?? event;
24586
24948
  let index = 0;
24587
24949
  for (const def of defs) {
24588
24950
  if ((def.type ?? "command") !== "command") continue;
@@ -24612,7 +24974,10 @@ function vibeEntryToCanonicalDef(raw) {
24612
24974
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
24613
24975
  if (vibeEvent === void 0) return null;
24614
24976
  if (isPrototypePollutionKey(vibeEvent)) return null;
24615
- const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
24977
+ const canonicalEvent = lookupOwn({
24978
+ record: VIBE_TO_CANONICAL_EVENT_NAMES,
24979
+ key: vibeEvent
24980
+ }) ?? vibeEvent;
24616
24981
  const def = { type: "command" };
24617
24982
  if (typeof entry.command === "string") def.command = entry.command;
24618
24983
  if (typeof entry.match === "string" && entry.match !== "" && entry.match !== "*") def.matcher = entry.match;
@@ -24637,7 +25002,10 @@ function vibeHooksToCanonical(parsed) {
24637
25002
  for (const raw of rawHooks) {
24638
25003
  const result = vibeEntryToCanonicalDef(raw);
24639
25004
  if (result === null) continue;
24640
- const list = canonical[result.canonicalEvent] ?? [];
25005
+ const list = lookupOwn({
25006
+ record: canonical,
25007
+ key: result.canonicalEvent
25008
+ }) ?? [];
24641
25009
  list.push(result.def);
24642
25010
  canonical[result.canonicalEvent] = list;
24643
25011
  }
@@ -25716,6 +26084,68 @@ var ClineIgnore = class ClineIgnore extends ToolIgnore {
25716
26084
  }
25717
26085
  };
25718
26086
  //#endregion
26087
+ //#region src/constants/crush-paths.ts
26088
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
26089
+ const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
26090
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
26091
+ const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
26092
+ const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
26093
+ //#endregion
26094
+ //#region src/features/ignore/crush-ignore.ts
26095
+ /**
26096
+ * Ignore generator for Crush.
26097
+ *
26098
+ * Crush excludes files from tool access via a `.crushignore` file, read
26099
+ * hierarchically (root and any subdirectory, the same way it walks
26100
+ * `.gitignore`) using gitignore syntax. Crush documents no global/user-scope
26101
+ * ignore file, so this is project-only.
26102
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/fsext/fileutil.go
26103
+ */
26104
+ var CrushIgnore = class CrushIgnore extends ToolIgnore {
26105
+ static getSettablePaths() {
26106
+ return {
26107
+ relativeDirPath: ".",
26108
+ relativeFilePath: CRUSH_IGNORE_FILE_NAME
26109
+ };
26110
+ }
26111
+ toRulesyncIgnore() {
26112
+ return new RulesyncIgnore({
26113
+ outputRoot: ".",
26114
+ relativeDirPath: ".",
26115
+ relativeFilePath: RULESYNC_AIIGNORE_RELATIVE_FILE_PATH,
26116
+ fileContent: this.fileContent
26117
+ });
26118
+ }
26119
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
26120
+ const body = rulesyncIgnore.getFileContent();
26121
+ return new CrushIgnore({
26122
+ outputRoot,
26123
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26124
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26125
+ fileContent: body
26126
+ });
26127
+ }
26128
+ static async fromFile({ outputRoot = process.cwd(), validate = true }) {
26129
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath));
26130
+ return new CrushIgnore({
26131
+ outputRoot,
26132
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26133
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26134
+ fileContent,
26135
+ validate
26136
+ });
26137
+ }
26138
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
26139
+ return new CrushIgnore({
26140
+ outputRoot,
26141
+ relativeDirPath,
26142
+ relativeFilePath,
26143
+ fileContent: "",
26144
+ validate: false
26145
+ });
26146
+ }
26147
+ };
26148
+ //#endregion
25719
26149
  //#region src/features/ignore/cursor-ignore.ts
25720
26150
  /**
25721
26151
  * Cursor ignore adapter.
@@ -26712,6 +27142,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
26712
27142
  ["claudecode", { class: ClaudecodeIgnore }],
26713
27143
  ["claudecode-legacy", { class: ClaudecodeIgnore }],
26714
27144
  ["cline", { class: ClineIgnore }],
27145
+ ["crush", { class: CrushIgnore }],
26715
27146
  ["cursor", { class: CursorIgnore }],
26716
27147
  ["hermesagent", { class: HermesagentIgnore }],
26717
27148
  ["junie", { class: JunieIgnore }],
@@ -28016,9 +28447,8 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
28016
28447
  throw new Error(`Failed to parse existing Codex CLI config at ${configTomlFilePath}: ${formatError(error)}`, { cause: error });
28017
28448
  }
28018
28449
  const strippedMcpServers = rulesyncMcp.getMcpServers();
28019
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
28020
28450
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
28021
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
28451
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
28022
28452
  return [serverName, {
28023
28453
  ...serverConfig,
28024
28454
  ...isRecord$1(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
@@ -30981,9 +31411,8 @@ var MusecodeMcp = class MusecodeMcp extends ToolMcp {
30981
31411
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
30982
31412
  const existingContent = await readFileContentOrNull(filePath) ?? "";
30983
31413
  const existing = parseMusecodeSettings(existingContent, filePath);
30984
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
30985
31414
  const converted = convertToMusecodeFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => {
30986
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
31415
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
30987
31416
  const mode = asMusecodeMode(isRecord$1(rawServer) ? rawServer.musecodeMode : void 0);
30988
31417
  return [serverName, {
30989
31418
  ...serverConfig,
@@ -31932,6 +32361,66 @@ async function readRovodevConfigYaml({ outputRoot }) {
31932
32361
  filePath: (0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)
31933
32362
  });
31934
32363
  }
32364
+ /**
32365
+ * Decide the absolute path a configured `mcpConfigPath` would name in the
32366
+ * given scope, without yet checking whether that path stays inside it. Split
32367
+ * out of resolveRovodevMcpImportPath so each function's branching stays
32368
+ * within the project's complexity budget.
32369
+ */
32370
+ function resolveMcpConfigCandidatePath({ outputRoot, global, normalizedPath, configuredPath }) {
32371
+ if (global && normalizedPath.startsWith("~/")) return { path: (0, node_path.resolve)(outputRoot, normalizedPath.slice(2)) };
32372
+ if (!global && normalizedPath.startsWith("~/")) return { rejectionMessage: `Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)} in project scope. A home-anchored path cannot be imported as part of a project, so importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32373
+ if ((0, node_path.isAbsolute)(normalizedPath)) return { path: (0, node_path.resolve)(normalizedPath) };
32374
+ if (!global) return { path: (0, node_path.resolve)(outputRoot, normalizedPath) };
32375
+ return { rejectionMessage: `Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)} in global scope. Only home-anchored or absolute paths can be imported safely, so importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32376
+ }
32377
+ /**
32378
+ * Resolve the active Rovo Dev MCP config without following a pointer outside
32379
+ * the import scope. The implementation is deliberately separate from
32380
+ * fromFile: it keeps path-policy decisions testable without changing the
32381
+ * public ToolMcp contract.
32382
+ */
32383
+ async function resolveRovodevMcpImportPath({ outputRoot, global, config, logger }) {
32384
+ const fallback = {
32385
+ filePath: (0, node_path.join)(outputRoot, ROVODEV_DIR, ROVODEV_MCP_FILE_NAME),
32386
+ relativeDirPath: ROVODEV_DIR,
32387
+ relativeFilePath: ROVODEV_MCP_FILE_NAME
32388
+ };
32389
+ const configuredPath = (config && isRecord$1(config.mcp) ? config.mcp : {}).mcpConfigPath;
32390
+ if (configuredPath === void 0) {
32391
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is unset in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which may not be the file Rovo Dev reads.`);
32392
+ return fallback;
32393
+ }
32394
+ if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
32395
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} must be a non-empty string. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32396
+ return fallback;
32397
+ }
32398
+ const normalizedPath = normalizeMcpConfigPathValue(configuredPath.trim());
32399
+ const candidateResult = resolveMcpConfigCandidatePath({
32400
+ outputRoot,
32401
+ global,
32402
+ normalizedPath,
32403
+ configuredPath
32404
+ });
32405
+ if ("rejectionMessage" in candidateResult) {
32406
+ logger?.warn(candidateResult.rejectionMessage);
32407
+ return fallback;
32408
+ }
32409
+ const candidatePath = candidateResult.path;
32410
+ const relativePath = (0, node_path.relative)((0, node_path.resolve)(outputRoot), candidatePath);
32411
+ if (relativePath === "" || splitPathSegments(normalizedPath).includes("..") || pathEscapesRoot(relativePath) || await resolvedPathEscapesRoot({
32412
+ rootPath: outputRoot,
32413
+ targetPath: candidatePath
32414
+ })) {
32415
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)}, which is outside the import scope or traverses a symbolic link. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32416
+ return fallback;
32417
+ }
32418
+ return {
32419
+ filePath: candidatePath,
32420
+ relativeDirPath: (0, node_path.dirname)(relativePath),
32421
+ relativeFilePath: (0, node_path.basename)(relativePath)
32422
+ };
32423
+ }
31935
32424
  function disabledNamesOf(config) {
31936
32425
  const mcpBlock = config && isRecord$1(config.mcp) ? config.mcp : {};
31937
32426
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
@@ -32037,6 +32526,25 @@ function envVarMcpFileSpellings({ fileName }) {
32037
32526
  return [`$HOME/${tail}`, `\${HOME}/${tail}`];
32038
32527
  }
32039
32528
  /**
32529
+ * Classify the existing `mcpConfigPath` without deciding how to report it.
32530
+ * Keep the known-file checks in this order: the generated file is valid in
32531
+ * either scope, while the documented default and environment-variable
32532
+ * spellings are global-only alternatives that need their own warnings.
32533
+ */
32534
+ function classifyExistingPointer({ existing, global, outputRoot }) {
32535
+ if (existing === void 0) return { kind: "unset" };
32536
+ const normalized = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32537
+ const namesFile = (fileName) => normalized !== void 0 && mcpFileSpellings({
32538
+ fileName,
32539
+ global,
32540
+ outputRoot
32541
+ }).includes(normalized);
32542
+ if (namesFile("mcp.json")) return { kind: "already-generated" };
32543
+ if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) return { kind: "documented-default" };
32544
+ if (global && normalized !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalized)) return { kind: "env-var-spelling" };
32545
+ return { kind: "unrelated" };
32546
+ }
32547
+ /**
32040
32548
  * Point `mcp.mcpConfigPath` at the `mcp.json` rulesync writes for this scope,
32041
32549
  * and report whether the block gained a value it did not already carry.
32042
32550
  *
@@ -32147,45 +32655,44 @@ function announcePointer({ global, logger }) {
32147
32655
  async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outputRoot, logger }) {
32148
32656
  const { pointer, configLabel, mcpLabel } = pointerLabels(global);
32149
32657
  const existing = existingMcp.mcpConfigPath;
32150
- const normalizedExisting = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32151
- const namesFile = (fileName) => normalizedExisting !== void 0 && mcpFileSpellings({
32152
- fileName,
32658
+ const classification = classifyExistingPointer({
32659
+ existing,
32153
32660
  global,
32154
32661
  outputRoot
32155
- }).includes(normalizedExisting);
32156
- const pointsAtGeneratedFile = namesFile(ROVODEV_MCP_FILE_NAME);
32662
+ });
32157
32663
  if (!hasLiveServers) {
32158
- if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} points at ${mcpLabel}, which now has no enabled server. Rovo Dev reads MCP servers from that file and nowhere else, so ${global ? "Rovo Dev has" : "this project has"} no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to ${global ? "Rovo Dev's own default" : "the global config"}.`);
32664
+ if (classification.kind === "already-generated") logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} points at ${mcpLabel}, which now has no enabled server. Rovo Dev reads MCP servers from that file and nowhere else, so ${global ? "Rovo Dev has" : "this project has"} no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to ${global ? "Rovo Dev's own default" : "the global config"}.`);
32159
32665
  return false;
32160
32666
  }
32161
- if (existing === void 0) {
32162
- const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32163
- if (displaced !== null) {
32164
- logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath unset in ${configLabel}, because ${displaced}. Atlassian documents that path and ${mcpLabel} as two different defaults for the setting, and mcpConfigPath names one config rather than merging, so pointing it at ${mcpLabel} would stop those servers being read on every project. Move the ones you want to keep into .rulesync/mcp.jsonc — or none at all, if it turns out to hold nothing you need — then set mcp.mcpConfigPath to "${pointer}" yourself, which rulesync will not overwrite.`);
32165
- return false;
32667
+ switch (classification.kind) {
32668
+ case "unset": {
32669
+ const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32670
+ if (displaced !== null) {
32671
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath unset in ${configLabel}, because ${displaced}. Atlassian documents that path and ${mcpLabel} as two different defaults for the setting, and mcpConfigPath names one config rather than merging, so pointing it at ${mcpLabel} would stop those servers being read on every project. Move the ones you want to keep into .rulesync/mcp.jsonc — or none at all, if it turns out to hold nothing you need — then set mcp.mcpConfigPath to "${pointer}" yourself, which rulesync will not overwrite.`);
32672
+ return false;
32673
+ }
32674
+ existingMcp.mcpConfigPath = pointer;
32675
+ announcePointer({
32676
+ global,
32677
+ logger
32678
+ });
32679
+ return true;
32166
32680
  }
32167
- existingMcp.mcpConfigPath = pointer;
32168
- announcePointer({
32169
- global,
32170
- logger
32171
- });
32172
- return true;
32173
- }
32174
- if (pointsAtGeneratedFile) return false;
32175
- if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) {
32176
- await warnAtDocumentedDefault({
32177
- existing,
32178
- outputRoot,
32179
- logger
32180
- });
32181
- return false;
32182
- }
32183
- if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
32184
- logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${quoteValueForWarning(existing)}. That names ${mcpLabel} only if Rovo Dev expands environment variables in this setting, which Atlassian does not document — if it does not, the path resolves literally and Rovo Dev reads no MCP servers at all. Write "${pointer}" instead, the form its own documented default uses.`);
32185
- return false;
32681
+ case "already-generated": return false;
32682
+ case "documented-default":
32683
+ await warnAtDocumentedDefault({
32684
+ existing,
32685
+ outputRoot,
32686
+ logger
32687
+ });
32688
+ return false;
32689
+ case "env-var-spelling":
32690
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${quoteValueForWarning(existing)}. That names ${mcpLabel} only if Rovo Dev expands environment variables in this setting, which Atlassian does not document — if it does not, the path resolves literally and Rovo Dev reads no MCP servers at all. Write "${pointer}" instead, the form its own documented default uses.`);
32691
+ return false;
32692
+ case "unrelated":
32693
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. Rovo Dev reads MCP servers from that path, so the generated ${mcpLabel} is unused until it is set to "${pointer}".`);
32694
+ return false;
32186
32695
  }
32187
- logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. Rovo Dev reads MCP servers from that path, so the generated ${mcpLabel} is unused until it is set to "${pointer}".`);
32188
- return false;
32189
32696
  }
32190
32697
  /**
32191
32698
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
@@ -32227,14 +32734,20 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32227
32734
  relativeFilePath: ROVODEV_MCP_FILE_NAME
32228
32735
  };
32229
32736
  }
32230
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32231
- const paths = this.getSettablePaths({ global });
32232
- const json = parseRovodevMcpJson(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32737
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false, logger }) {
32738
+ const rovodevConfig = await readRovodevConfigYaml({ outputRoot });
32739
+ const paths = await resolveRovodevMcpImportPath({
32740
+ outputRoot,
32741
+ global,
32742
+ config: rovodevConfig,
32743
+ logger
32744
+ });
32745
+ const json = parseRovodevMcpJson(await readFileContentOrNull(paths.filePath) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32233
32746
  const newJson = {
32234
32747
  ...json,
32235
32748
  mcpServers: json.mcpServers ?? {}
32236
32749
  };
32237
- const disabledNames = disabledNamesOf(await readRovodevConfigYaml({ outputRoot }));
32750
+ const disabledNames = disabledNamesOf(rovodevConfig);
32238
32751
  if (disabledNames.length > 0 && isMcpServers(newJson.mcpServers)) {
32239
32752
  const servers = newJson.mcpServers;
32240
32753
  for (const name of disabledNames) {
@@ -32264,9 +32777,8 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32264
32777
  } catch {
32265
32778
  canWriteDisableToggle = false;
32266
32779
  }
32267
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
32268
32780
  const mcpServers = Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => {
32269
- const rawServer = isRecord$1(rawMcpServers) && Object.hasOwn(rawMcpServers, name) ? rawMcpServers[name] : void 0;
32781
+ const rawServer = rulesyncMcp.getRawMcpServer(name);
32270
32782
  const record = {
32271
32783
  ...server,
32272
32784
  ...readEnableInstructions(rawServer) && { rovodevEnableInstructions: true }
@@ -34254,6 +34766,518 @@ function convertAmpToRulesync({ disable, permissions }) {
34254
34766
  return { permission };
34255
34767
  }
34256
34768
  //#endregion
34769
+ //#region src/utils/glob.ts
34770
+ /**
34771
+ * Convert a glob-like pattern into an anchored regex source string.
34772
+ *
34773
+ * Only `*` (any run of characters) and `?` (one character) carry meaning;
34774
+ * every other regex metacharacter is escaped so it matches literally. The
34775
+ * result is anchored at both ends, because the callers ask "is this the whole
34776
+ * name?" rather than "does this appear somewhere in it?".
34777
+ *
34778
+ * Note that `[` and `]` are escaped along with everything else, so a bracket
34779
+ * class is a literal here while `matchesGlob` below reads it as a class. The
34780
+ * one caller wants exactly that: AugmentCode writes this source into its own
34781
+ * config as the tool's own shell-command regex, and never executes it, so it
34782
+ * has to say what the tool would read rather than what a glob means. Use
34783
+ * `matchesGlob` for an actual comparison.
34784
+ */
34785
+ function globToAnchoredRegexSource(glob) {
34786
+ let source = "";
34787
+ for (const char of glob) if (char === "*") source += ".*";
34788
+ else if (char === "?") source += ".";
34789
+ else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34790
+ else source += char;
34791
+ return `^${source}$`;
34792
+ }
34793
+ /**
34794
+ * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34795
+ * bracket is never closed — in which case it is an ordinary character.
34796
+ */
34797
+ function parseGlobClass(characters, start) {
34798
+ let index = start;
34799
+ const negated = characters[index] === "!" || characters[index] === "^";
34800
+ if (negated) index += 1;
34801
+ const members = /* @__PURE__ */ new Set();
34802
+ const ranges = [];
34803
+ let first = true;
34804
+ while (index < characters.length) {
34805
+ const character = characters[index] ?? "";
34806
+ if (character === "]" && !first) return {
34807
+ step: {
34808
+ kind: "class",
34809
+ negated,
34810
+ members,
34811
+ ranges
34812
+ },
34813
+ next: index + 1
34814
+ };
34815
+ first = false;
34816
+ const high = characters[index + 2];
34817
+ if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34818
+ ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34819
+ index += 3;
34820
+ continue;
34821
+ }
34822
+ members.add(character);
34823
+ index += 1;
34824
+ }
34825
+ }
34826
+ /** Split a glob into the steps `matchesGlob` walks. */
34827
+ function parseGlob(glob) {
34828
+ const characters = [...glob];
34829
+ const steps = [];
34830
+ let index = 0;
34831
+ let bracketsAreClosed = true;
34832
+ while (index < characters.length) {
34833
+ const character = characters[index] ?? "";
34834
+ index += 1;
34835
+ if (character === "*") {
34836
+ if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34837
+ continue;
34838
+ }
34839
+ if (character === "?") {
34840
+ steps.push({ kind: "any" });
34841
+ continue;
34842
+ }
34843
+ if (character === "[" && bracketsAreClosed) {
34844
+ const parsed = parseGlobClass(characters, index);
34845
+ if (parsed === void 0) bracketsAreClosed = false;
34846
+ else {
34847
+ steps.push(parsed.step);
34848
+ index = parsed.next;
34849
+ continue;
34850
+ }
34851
+ }
34852
+ steps.push({
34853
+ kind: "literal",
34854
+ character
34855
+ });
34856
+ }
34857
+ return steps;
34858
+ }
34859
+ function matchesGlobStep(step, character) {
34860
+ if (step.kind === "star") return false;
34861
+ if (step.kind === "any") return true;
34862
+ if (step.kind === "literal") return step.character === character;
34863
+ const code = character.codePointAt(0) ?? 0;
34864
+ const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34865
+ return step.negated ? !admitted : admitted;
34866
+ }
34867
+ /** Whether two single-character steps can both match one same character. */
34868
+ function stepsShareACharacter(left, right) {
34869
+ if (left.kind === "any" || right.kind === "any") return true;
34870
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34871
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34872
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34873
+ return true;
34874
+ }
34875
+ /** Whether every step from `index` on can match the empty string. */
34876
+ function isAllStars(steps, index) {
34877
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34878
+ return true;
34879
+ }
34880
+ /**
34881
+ * The most work one intersection walk will do, counted in cells times the cost
34882
+ * of one. Past it the two patterns are reported as intersecting without being
34883
+ * walked: the product of two lengths grows quadratically, and a pattern long
34884
+ * enough to reach this is pathological rather than a command anybody typed.
34885
+ * Answering `true` withholds an `allow`, which is the direction that fails
34886
+ * closed.
34887
+ */
34888
+ const MAX_INTERSECTION_CELLS = 1e6;
34889
+ /**
34890
+ * The most work a whole run of comparisons will do. A caller holding R
34891
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34892
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34893
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34894
+ * that together take minutes. The shared budget is spent down across the run
34895
+ * and, once it is gone, every remaining pair is reported as intersecting —
34896
+ * again the direction that withholds an `allow` rather than writing one.
34897
+ */
34898
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34899
+ /**
34900
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34901
+ * filling the two rows the table is held in, and collecting the answer.
34902
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34903
+ * one-step patterns walks a single cell, so n short restrictions against n
34904
+ * short allow rules is n squared comparisons that never spend the budget down
34905
+ * however many of them there are. Charging a floor per pair puts pair count and
34906
+ * walk length on the same exhaustible resource.
34907
+ *
34908
+ * For the short patterns of an ordinary config the floor is the whole charge,
34909
+ * which lowers how many pairs a run compares from around a million to about
34910
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34911
+ * that line withholds every allow it has not yet compared, the same fail-closed
34912
+ * answer exhaustion gives everywhere else.
34913
+ */
34914
+ const INTERSECTION_PAIR_COST = 64;
34915
+ /**
34916
+ * A budget for one caller's run of comparisons. Hand the same one to every
34917
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34918
+ * config — so the run as a whole stays bounded rather than only each pair in
34919
+ * it.
34920
+ */
34921
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34922
+ return { remaining };
34923
+ }
34924
+ /**
34925
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34926
+ * the same pattern against a whole list parses it once and reuses the result.
34927
+ */
34928
+ function parseGlobPattern(glob) {
34929
+ const steps = parseGlob(glob);
34930
+ return {
34931
+ steps,
34932
+ maxRanges: maxRangeCount(steps)
34933
+ };
34934
+ }
34935
+ /**
34936
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
34937
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
34938
+ * thousands of them turns a walk that looks affordable by cell count alone into
34939
+ * a quadratic one — which is why the budget is spent on cells times this rather
34940
+ * than on cells.
34941
+ */
34942
+ function maxRangeCount(steps) {
34943
+ let most = 0;
34944
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34945
+ return most;
34946
+ }
34947
+ /**
34948
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
34949
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
34950
+ * Once that budget is exhausted every further pair answers `true` without being
34951
+ * walked, so a caller reading the answer as a reason to restrict stays on the
34952
+ * safe side.
34953
+ */
34954
+ function parsedGlobsIntersect(left, right, budget) {
34955
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34956
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
34957
+ const cost = rows.length * columns.length * cellCost;
34958
+ if (cost > MAX_INTERSECTION_CELLS) return true;
34959
+ if (budget !== void 0) {
34960
+ const charge = cost + INTERSECTION_PAIR_COST;
34961
+ if (charge > budget.remaining) {
34962
+ budget.remaining = 0;
34963
+ return true;
34964
+ }
34965
+ budget.remaining -= charge;
34966
+ }
34967
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34968
+ for (let i = rows.length - 1; i >= 0; i--) {
34969
+ const row = Array.from({ length: columns.length + 1 }, () => false);
34970
+ row[columns.length] = isAllStars(rows, i);
34971
+ for (let j = columns.length - 1; j >= 0; j--) {
34972
+ const rowStep = rows[i];
34973
+ const columnStep = columns[j];
34974
+ if (rowStep === void 0 || columnStep === void 0) continue;
34975
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
34976
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34977
+ continue;
34978
+ }
34979
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
34980
+ }
34981
+ next = row;
34982
+ }
34983
+ return next[0] ?? false;
34984
+ }
34985
+ //#endregion
34986
+ //#region src/features/permissions/shell-command-categories.ts
34987
+ /** The canonical category that names a shell command's permissions. */
34988
+ const SHELL_PERMISSION_CATEGORY = "bash";
34989
+ /**
34990
+ * Collect the canonical rules that govern shell commands, for the adapters
34991
+ * whose tool models commands and nothing else.
34992
+ *
34993
+ * The `bash` category contributes every rule. The all-tools `*` category
34994
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
34995
+ * written there covers shell commands too, and dropping it inverts the
34996
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
34997
+ * an adapter that reads only `bash` auto-approves the very command the file
34998
+ * denies.
34999
+ *
35000
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35001
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
35002
+ * carrying it in the restricting direction only over-restricts, while carrying
35003
+ * it in the permissive direction would grant something the author never said
35004
+ * about commands. Both directions therefore fail closed.
35005
+ */
35006
+ function collectShellCommandRules(permission) {
35007
+ const rules = [];
35008
+ const foreignRestrictingCategories = [];
35009
+ const ignoredAllToolsAllowPatterns = [];
35010
+ for (const [category, categoryRules] of Object.entries(permission)) {
35011
+ if (category === "bash") {
35012
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35013
+ pattern,
35014
+ action,
35015
+ fromAllToolsCategory: false
35016
+ });
35017
+ continue;
35018
+ }
35019
+ if (category === "*") {
35020
+ for (const [pattern, action] of Object.entries(categoryRules)) {
35021
+ if (action === "allow") {
35022
+ ignoredAllToolsAllowPatterns.push(pattern);
35023
+ continue;
35024
+ }
35025
+ rules.push({
35026
+ pattern,
35027
+ action,
35028
+ fromAllToolsCategory: true
35029
+ });
35030
+ }
35031
+ continue;
35032
+ }
35033
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35034
+ }
35035
+ return {
35036
+ rules,
35037
+ foreignRestrictingCategories,
35038
+ ignoredAllToolsAllowPatterns
35039
+ };
35040
+ }
35041
+ /**
35042
+ * Build the test an adapter applies to an `allow` pattern before writing it:
35043
+ * which restrictions it cannot write name some of the same commands? The
35044
+ * answer is the list of those restrictions — empty when the `allow` may be
35045
+ * written — so a caller can report both the allow rules it withheld and the
35046
+ * restrictions that withheld nothing.
35047
+ *
35048
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35049
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35050
+ * asking whether any one command matches both. Width does not enter into it: an
35051
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35052
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35053
+ * on every `git ... --force` command even though neither pattern covers the
35054
+ * other's spelling. Comparing only identical spellings would let the most
35055
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35056
+ *
35057
+ * Identical spellings are still compared as strings first, as a shortcut past
35058
+ * the walk for the commonest case.
35059
+ *
35060
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
35061
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
35062
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
35063
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
35064
+ * every tool, so it is a glob already — and is compared as it stands. The
35065
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
35066
+ * withholds an allow rather than writing one the config restricts — see
35067
+ * `warpCommandPatternToGlob`.
35068
+ */
35069
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35070
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35071
+ pattern,
35072
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35073
+ }));
35074
+ return (allowPattern) => {
35075
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35076
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35077
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35078
+ };
35079
+ }
35080
+ /**
35081
+ * Which of the given all-tools `*` restrictions look like they may not name a
35082
+ * command at all — the question a `deny` and an `ask` written there both raise.
35083
+ *
35084
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35085
+ * rules has nothing to withhold, and a pattern the author also wrote under
35086
+ * `bash` is a command on their own word. Both are excluded, so what remains is
35087
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35088
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
35089
+ *
35090
+ * A `bash` restriction never belongs here: it names a command by construction,
35091
+ * so overlapping no allow rule says nothing is wrong with it.
35092
+ */
35093
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35094
+ if (!rules.some(({ action }) => action === "allow")) return [];
35095
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35096
+ return (0, es_toolkit.uniq)(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35097
+ }
35098
+ /**
35099
+ * Split shell-command rules into the allow and deny lists of a tool that models
35100
+ * commands with those two tiers and nothing else.
35101
+ *
35102
+ * `ask` has no list of its own — such a tool already prompts for whatever it
35103
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35104
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
35105
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
35106
+ * about would answer the prompt the author wanted.
35107
+ *
35108
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35109
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
35110
+ * regular expressions rather than globs, and writing any denylist **replaces**
35111
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35112
+ * the tool's own protection for a rule that matches no command. Where the deny
35113
+ * cannot be written it withholds the allow rules it covers instead, which
35114
+ * restricts in the same direction without touching the denylist.
35115
+ *
35116
+ * A `bash` deny withholds nothing: it names a command by construction, so the
35117
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35118
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35119
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35120
+ * even where it is written: a pattern under `*` need not name a command —
35121
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35122
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
35123
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35124
+ * command pattern is reported; failing open would not be.
35125
+ *
35126
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35127
+ * patterns are not globs.
35128
+ */
35129
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35130
+ const deny = [];
35131
+ const unwrittenDenyPatterns = [];
35132
+ const restrictions = [];
35133
+ const writtenAllToolsDenyPatterns = [];
35134
+ const allToolsAskPatterns = [];
35135
+ for (const rule of rules) {
35136
+ const { pattern, action, fromAllToolsCategory } = rule;
35137
+ if (action === "allow") continue;
35138
+ if (action !== "deny") {
35139
+ restrictions.push(rule);
35140
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35141
+ continue;
35142
+ }
35143
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
35144
+ deny.push(pattern);
35145
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35146
+ } else unwrittenDenyPatterns.push(pattern);
35147
+ if (fromAllToolsCategory) restrictions.push(rule);
35148
+ }
35149
+ const budget = createIntersectionBudget();
35150
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35151
+ normalizePattern,
35152
+ budget
35153
+ });
35154
+ const allow = [];
35155
+ const shadowedAllowPatterns = [];
35156
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35157
+ for (const { pattern, action } of rules) {
35158
+ if (action !== "allow") continue;
35159
+ const shadowing = shadowingRestrictions(pattern);
35160
+ if (shadowing.length > 0) {
35161
+ shadowedAllowPatterns.push(pattern);
35162
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35163
+ continue;
35164
+ }
35165
+ allow.push(pattern);
35166
+ }
35167
+ return {
35168
+ allow,
35169
+ deny,
35170
+ shadowedAllowPatterns,
35171
+ unwrittenDenyPatterns,
35172
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35173
+ rules,
35174
+ allToolsPatterns: writtenAllToolsDenyPatterns,
35175
+ withholdingPatterns
35176
+ }),
35177
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35178
+ rules,
35179
+ allToolsPatterns: allToolsAskPatterns,
35180
+ withholdingPatterns
35181
+ }),
35182
+ intersectionBudgetExhausted: budget.remaining === 0
35183
+ };
35184
+ }
35185
+ /**
35186
+ * Report, for one command-only tool, every canonical rule its two lists could
35187
+ * not carry. Every command-only adapter shares this reporting, so a rule
35188
+ * dropped in one is worded the same way in all.
35189
+ */
35190
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35191
+ if (intersectionBudgetExhausted) warnWithFallback(logger, `${toolLabel} reached the limit on how much work one generation may spend comparing .rulesync/permissions.jsonc's allow rules against its deny and ask rules, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than the file asks for. Write fewer or shorter command patterns to have them all compared.`);
35192
+ for (const category of foreignRestrictingCategories) warnWithFallback(logger, `${toolLabel} only models shell-command permissions (${surfaceLabel}); '${category}' deny and ask rules cannot be represented and were skipped.`);
35193
+ if (unwrittenDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} did not write the all-tools '*' deny rule(s) for ${unwrittenDenyPatterns.join(", ")} into its denylist.${unwrittenDenyReason === void 0 ? "" : ` ${unwrittenDenyReason}`} They restrict only by withholding the allow rules they cover; write them under 'bash' to have them enforced as commands.`);
35194
+ if (unenforcedAllToolsDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} wrote the all-tools '*' deny rule(s) for ${unenforcedAllToolsDenyPatterns.join(", ")} into its denylist as they stand, but they withheld none of the allow rules beside them. A pattern written under '*' need not name a command — 'secrets/**' there denies a path — and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern.`);
35195
+ if (unenforcedAllToolsAskPatterns.length > 0) warnWithFallback(logger, `${toolLabel} has no ask tier (${surfaceLabel}), so the all-tools '*' ask rule(s) for ${unenforcedAllToolsAskPatterns.join(", ")} restrict only by withholding the allow rules they cover — and they covered none. A pattern written under '*' need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns.`);
35196
+ if (ignoredAllToolsAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} reads the all-tools '*' category for its deny and ask rules only, so the allow rule(s) for ${ignoredAllToolsAllowPatterns.join(", ")} were skipped — a pattern written under '*' need not be a command. Write them under 'bash' to auto-approve them as commands.`);
35197
+ if (shadowedAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} was not given the allow rule(s) for ${shadowedAllowPatterns.join(", ")} because .rulesync/permissions.jsonc restricts the same commands elsewhere, and the stricter rule wins whatever its width.`);
35198
+ }
35199
+ function resolveShellCommandState(permission, writesAllToolsDeny) {
35200
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35201
+ const partitioned = partitionCommandRules({
35202
+ rules,
35203
+ writesAllToolsDeny
35204
+ });
35205
+ return {
35206
+ allow: partitioned.allow,
35207
+ deny: partitioned.deny,
35208
+ bash: bashRulesHonoringAllTools(permission),
35209
+ foreignRestrictingCategories,
35210
+ ignoredAllToolsAllowPatterns,
35211
+ shadowedAllowPatterns: partitioned.shadowedAllowPatterns,
35212
+ unwrittenDenyPatterns: partitioned.unwrittenDenyPatterns,
35213
+ unenforcedAllToolsDenyPatterns: partitioned.unenforcedAllToolsDenyPatterns,
35214
+ unenforcedAllToolsAskPatterns: partitioned.unenforcedAllToolsAskPatterns,
35215
+ intersectionBudgetExhausted: partitioned.intersectionBudgetExhausted
35216
+ };
35217
+ }
35218
+ /**
35219
+ * Collect shell-command allow/deny lists the way the command-only adapters do,
35220
+ * and report every restriction the surface cannot carry.
35221
+ */
35222
+ function resolveShellCommandLists({ permission, writesAllToolsDeny, toolLabel, surfaceLabel, logger }) {
35223
+ const resolved = resolveShellCommandState(permission, writesAllToolsDeny);
35224
+ warnAboutUnwrittenCommandRules({
35225
+ toolLabel,
35226
+ surfaceLabel,
35227
+ foreignRestrictingCategories: resolved.foreignRestrictingCategories,
35228
+ shadowedAllowPatterns: resolved.shadowedAllowPatterns,
35229
+ unwrittenDenyPatterns: resolved.unwrittenDenyPatterns,
35230
+ unenforcedAllToolsDenyPatterns: resolved.unenforcedAllToolsDenyPatterns,
35231
+ unenforcedAllToolsAskPatterns: resolved.unenforcedAllToolsAskPatterns,
35232
+ ignoredAllToolsAllowPatterns: resolved.ignoredAllToolsAllowPatterns,
35233
+ intersectionBudgetExhausted: resolved.intersectionBudgetExhausted,
35234
+ logger
35235
+ });
35236
+ return {
35237
+ allow: resolved.allow,
35238
+ deny: resolved.deny,
35239
+ bash: resolved.bash
35240
+ };
35241
+ }
35242
+ /**
35243
+ * The `bash` category after all-tools `*` restrictions have been applied. A
35244
+ * `deny`/`ask` written under `*` covers shell commands too, so a bash `allow`
35245
+ * it overlaps is withheld, a `*` deny is copied in, and a `*` ask is copied in
35246
+ * wherever `bash` says nothing about that exact pattern yet — otherwise it
35247
+ * would vanish from the resolved category entirely rather than falling back to
35248
+ * a tier that still prompts. An existing `bash` entry for the same pattern is
35249
+ * never downgraded by a `*` ask (a bash `allow` was already dropped above, and
35250
+ * a bash `deny`/`ask` there is at least as strict already).
35251
+ */
35252
+ function bashRulesHonoringAllTools(permission) {
35253
+ const { rules } = collectShellCommandRules(permission);
35254
+ const allToolsRestrictions = rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory);
35255
+ const shadowingRestrictions = createShadowingRestrictionsTest(allToolsRestrictions);
35256
+ const bash = { ...permission.bash };
35257
+ for (const [pattern, action] of Object.entries(bash)) if (action === "allow" && shadowingRestrictions(pattern).length > 0) delete bash[pattern];
35258
+ for (const { pattern, action } of allToolsRestrictions) {
35259
+ if (isPrototypePollutionKey(pattern)) continue;
35260
+ if (action === "deny") {
35261
+ if (bash[pattern] !== "ask") bash[pattern] = "deny";
35262
+ continue;
35263
+ }
35264
+ if (bash[pattern] === void 0) bash[pattern] = "ask";
35265
+ }
35266
+ return bash;
35267
+ }
35268
+ /**
35269
+ * Return a permission block whose `bash` category honors all-tools `*`
35270
+ * restrictions. Other categories are unchanged, so adapters that already model
35271
+ * `*` keep doing so.
35272
+ */
35273
+ function honorAllToolsOnBash(permission) {
35274
+ if (permission.bash === void 0) return permission;
35275
+ return {
35276
+ ...permission,
35277
+ bash: bashRulesHonoringAllTools(permission)
35278
+ };
35279
+ }
35280
+ //#endregion
34257
35281
  //#region src/features/permissions/antigravity-cli-permissions.ts
34258
35282
  /**
34259
35283
  * Top-level `~/.gemini/antigravity-cli/settings.json` keys the `antigravity-cli`
@@ -34501,7 +35525,7 @@ function convertRulesyncToAntigravityCliPermissions(config) {
34501
35525
  const allow = [];
34502
35526
  const ask = [];
34503
35527
  const deny = [];
34504
- for (const [category, rules] of Object.entries(config.permission)) {
35528
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34505
35529
  const cliToolName = toAntigravityCliToolName(category);
34506
35530
  for (const [pattern, action] of Object.entries(rules)) {
34507
35531
  const entry = buildPermissionEntry$1(cliToolName, pattern);
@@ -34712,7 +35736,7 @@ function convertRulesyncToAntigravityIdePermissions(config) {
34712
35736
  const allow = [];
34713
35737
  const ask = [];
34714
35738
  const deny = [];
34715
- for (const [category, rules] of Object.entries(config.permission)) {
35739
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34716
35740
  const action = toIdeAction(category);
34717
35741
  for (const [pattern, permissionAction] of Object.entries(rules)) {
34718
35742
  const entry = buildPermissionEntry(action, pattern);
@@ -34752,223 +35776,6 @@ function convertAntigravityIdeToRulesyncPermissions(params) {
34752
35776
  return { permission };
34753
35777
  }
34754
35778
  //#endregion
34755
- //#region src/utils/glob.ts
34756
- /**
34757
- * Convert a glob-like pattern into an anchored regex source string.
34758
- *
34759
- * Only `*` (any run of characters) and `?` (one character) carry meaning;
34760
- * every other regex metacharacter is escaped so it matches literally. The
34761
- * result is anchored at both ends, because the callers ask "is this the whole
34762
- * name?" rather than "does this appear somewhere in it?".
34763
- *
34764
- * Note that `[` and `]` are escaped along with everything else, so a bracket
34765
- * class is a literal here while `matchesGlob` below reads it as a class. The
34766
- * one caller wants exactly that: AugmentCode writes this source into its own
34767
- * config as the tool's own shell-command regex, and never executes it, so it
34768
- * has to say what the tool would read rather than what a glob means. Use
34769
- * `matchesGlob` for an actual comparison.
34770
- */
34771
- function globToAnchoredRegexSource(glob) {
34772
- let source = "";
34773
- for (const char of glob) if (char === "*") source += ".*";
34774
- else if (char === "?") source += ".";
34775
- else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34776
- else source += char;
34777
- return `^${source}$`;
34778
- }
34779
- /**
34780
- * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34781
- * bracket is never closed — in which case it is an ordinary character.
34782
- */
34783
- function parseGlobClass(characters, start) {
34784
- let index = start;
34785
- const negated = characters[index] === "!" || characters[index] === "^";
34786
- if (negated) index += 1;
34787
- const members = /* @__PURE__ */ new Set();
34788
- const ranges = [];
34789
- let first = true;
34790
- while (index < characters.length) {
34791
- const character = characters[index] ?? "";
34792
- if (character === "]" && !first) return {
34793
- step: {
34794
- kind: "class",
34795
- negated,
34796
- members,
34797
- ranges
34798
- },
34799
- next: index + 1
34800
- };
34801
- first = false;
34802
- const high = characters[index + 2];
34803
- if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34804
- ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34805
- index += 3;
34806
- continue;
34807
- }
34808
- members.add(character);
34809
- index += 1;
34810
- }
34811
- }
34812
- /** Split a glob into the steps `matchesGlob` walks. */
34813
- function parseGlob(glob) {
34814
- const characters = [...glob];
34815
- const steps = [];
34816
- let index = 0;
34817
- let bracketsAreClosed = true;
34818
- while (index < characters.length) {
34819
- const character = characters[index] ?? "";
34820
- index += 1;
34821
- if (character === "*") {
34822
- if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34823
- continue;
34824
- }
34825
- if (character === "?") {
34826
- steps.push({ kind: "any" });
34827
- continue;
34828
- }
34829
- if (character === "[" && bracketsAreClosed) {
34830
- const parsed = parseGlobClass(characters, index);
34831
- if (parsed === void 0) bracketsAreClosed = false;
34832
- else {
34833
- steps.push(parsed.step);
34834
- index = parsed.next;
34835
- continue;
34836
- }
34837
- }
34838
- steps.push({
34839
- kind: "literal",
34840
- character
34841
- });
34842
- }
34843
- return steps;
34844
- }
34845
- function matchesGlobStep(step, character) {
34846
- if (step.kind === "star") return false;
34847
- if (step.kind === "any") return true;
34848
- if (step.kind === "literal") return step.character === character;
34849
- const code = character.codePointAt(0) ?? 0;
34850
- const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34851
- return step.negated ? !admitted : admitted;
34852
- }
34853
- /** Whether two single-character steps can both match one same character. */
34854
- function stepsShareACharacter(left, right) {
34855
- if (left.kind === "any" || right.kind === "any") return true;
34856
- if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34857
- if (left.kind === "literal") return matchesGlobStep(right, left.character);
34858
- if (right.kind === "literal") return matchesGlobStep(left, right.character);
34859
- return true;
34860
- }
34861
- /** Whether every step from `index` on can match the empty string. */
34862
- function isAllStars(steps, index) {
34863
- for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34864
- return true;
34865
- }
34866
- /**
34867
- * The most work one intersection walk will do, counted in cells times the cost
34868
- * of one. Past it the two patterns are reported as intersecting without being
34869
- * walked: the product of two lengths grows quadratically, and a pattern long
34870
- * enough to reach this is pathological rather than a command anybody typed.
34871
- * Answering `true` withholds an `allow`, which is the direction that fails
34872
- * closed.
34873
- */
34874
- const MAX_INTERSECTION_CELLS = 1e6;
34875
- /**
34876
- * The most work a whole run of comparisons will do. A caller holding R
34877
- * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34878
- * bounds none of that: a hundred restrictions against a hundred allow rules,
34879
- * each pattern just under the per-pair cap, is ten thousand affordable walks
34880
- * that together take minutes. The shared budget is spent down across the run
34881
- * and, once it is gone, every remaining pair is reported as intersecting —
34882
- * again the direction that withholds an `allow` rather than writing one.
34883
- */
34884
- const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34885
- /**
34886
- * What a pair costs on top of the cells it walks: the call itself, sizing and
34887
- * filling the two rows the table is held in, and collecting the answer.
34888
- * Charging only cells would leave the *number* of pairs unbounded — a pair of
34889
- * one-step patterns walks a single cell, so n short restrictions against n
34890
- * short allow rules is n squared comparisons that never spend the budget down
34891
- * however many of them there are. Charging a floor per pair puts pair count and
34892
- * walk length on the same exhaustible resource.
34893
- *
34894
- * For the short patterns of an ordinary config the floor is the whole charge,
34895
- * which lowers how many pairs a run compares from around a million to about
34896
- * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34897
- * that line withholds every allow it has not yet compared, the same fail-closed
34898
- * answer exhaustion gives everywhere else.
34899
- */
34900
- const INTERSECTION_PAIR_COST = 64;
34901
- /**
34902
- * A budget for one caller's run of comparisons. Hand the same one to every
34903
- * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34904
- * config — so the run as a whole stays bounded rather than only each pair in
34905
- * it.
34906
- */
34907
- function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34908
- return { remaining };
34909
- }
34910
- /**
34911
- * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34912
- * the same pattern against a whole list parses it once and reuses the result.
34913
- */
34914
- function parseGlobPattern(glob) {
34915
- const steps = parseGlob(glob);
34916
- return {
34917
- steps,
34918
- maxRanges: maxRangeCount(steps)
34919
- };
34920
- }
34921
- /**
34922
- * What one cell can cost, as a multiplier on the cell count. A literal met by a
34923
- * `[a-z...]` class walks that class's ranges, so a single class carrying
34924
- * thousands of them turns a walk that looks affordable by cell count alone into
34925
- * a quadratic one — which is why the budget is spent on cells times this rather
34926
- * than on cells.
34927
- */
34928
- function maxRangeCount(steps) {
34929
- let most = 0;
34930
- for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34931
- return most;
34932
- }
34933
- /**
34934
- * `globsIntersect` for two globs already parsed, optionally spending a budget
34935
- * shared with the rest of the caller's run — see `createIntersectionBudget`.
34936
- * Once that budget is exhausted every further pair answers `true` without being
34937
- * walked, so a caller reading the answer as a reason to restrict stays on the
34938
- * safe side.
34939
- */
34940
- function parsedGlobsIntersect(left, right, budget) {
34941
- const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34942
- const cellCost = 1 + left.maxRanges + right.maxRanges;
34943
- const cost = rows.length * columns.length * cellCost;
34944
- if (cost > MAX_INTERSECTION_CELLS) return true;
34945
- if (budget !== void 0) {
34946
- const charge = cost + INTERSECTION_PAIR_COST;
34947
- if (charge > budget.remaining) {
34948
- budget.remaining = 0;
34949
- return true;
34950
- }
34951
- budget.remaining -= charge;
34952
- }
34953
- let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34954
- for (let i = rows.length - 1; i >= 0; i--) {
34955
- const row = Array.from({ length: columns.length + 1 }, () => false);
34956
- row[columns.length] = isAllStars(rows, i);
34957
- for (let j = columns.length - 1; j >= 0; j--) {
34958
- const rowStep = rows[i];
34959
- const columnStep = columns[j];
34960
- if (rowStep === void 0 || columnStep === void 0) continue;
34961
- if (rowStep.kind === "star" || columnStep.kind === "star") {
34962
- row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34963
- continue;
34964
- }
34965
- row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
34966
- }
34967
- next = row;
34968
- }
34969
- return next[0] ?? false;
34970
- }
34971
- //#endregion
34972
35779
  //#region src/features/permissions/augmentcode-permissions.ts
34973
35780
  const moduleLogger$2 = fallbackLogger;
34974
35781
  zod_mini.z.enum([
@@ -35220,6 +36027,7 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35220
36027
  const basicExistingEntries = existingEntries.filter((entry) => !isSpecialEntry(entry));
35221
36028
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
35222
36029
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
36030
+ if (entry.toolName === "*") return false;
35223
36031
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
35224
36032
  if (entry.permission.type === "deny") {
35225
36033
  const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
@@ -35297,7 +36105,18 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35297
36105
  };
35298
36106
  function convertRulesyncToAugmentEntries({ config, logger }) {
35299
36107
  const entries = [];
35300
- for (const [category, rules] of Object.entries(config.permission)) {
36108
+ const resolvedBashRules = bashRulesHonoringAllTools(config.permission);
36109
+ const permission = config.permission.bash !== void 0 || Object.keys(resolvedBashRules).length > 0 ? {
36110
+ ...config.permission,
36111
+ bash: resolvedBashRules
36112
+ } : config.permission;
36113
+ const allToolsFailClosedType = computeAllToolsFailClosedType(config.permission["*"]);
36114
+ const categoriesWithOwnEntries = /* @__PURE__ */ new Set();
36115
+ for (const [category, rules] of Object.entries(permission)) {
36116
+ if (category === "*") {
36117
+ logger?.warn("AugmentCode permissions: the all-tools '*' category cannot be emitted as a single entry because AugmentCode has no wildcard toolName. Deny/ask rules are folded into 'launch-process' and, for any other managed tool that has no explicit rules of its own, applied as a blanket deny/ask; all-tools allow rules and tools that already define their own rules are left untouched.");
36118
+ continue;
36119
+ }
35301
36120
  const augmentToolName = toAugmentToolName(category);
35302
36121
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(augmentToolName) && augmentToolName === category) logger?.warn(`AugmentCode permissions: passing through unknown tool category '${category}' as toolName.`);
35303
36122
  if (augmentToolName === "launch-process") {
@@ -35322,19 +36141,50 @@ function convertRulesyncToAugmentEntries({ config, logger }) {
35322
36141
  toolName: augmentToolName,
35323
36142
  permission: { type: "deny" }
35324
36143
  });
36144
+ categoriesWithOwnEntries.add(category);
35325
36145
  continue;
35326
36146
  }
35327
36147
  const droppedPatterns = [];
35328
- for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") entries.push({
35329
- toolName: augmentToolName,
35330
- permission: { type: actionToAugmentType(action) }
35331
- });
35332
- else droppedPatterns.push(pattern);
36148
+ for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") {
36149
+ entries.push({
36150
+ toolName: augmentToolName,
36151
+ permission: { type: actionToAugmentType(action) }
36152
+ });
36153
+ categoriesWithOwnEntries.add(category);
36154
+ } else droppedPatterns.push(pattern);
35333
36155
  if (droppedPatterns.length > 0) logger?.warn(`AugmentCode permissions: dropping non-wildcard patterns for category '${category}' (${droppedPatterns.join(", ")}); AugmentCode does not document a per-input matcher for this tool. Use a 'deny' rule with pattern '*' if you need to block this tool entirely.`);
35334
36156
  }
36157
+ entries.push(...synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType));
35335
36158
  return entries;
35336
36159
  }
35337
36160
  /**
36161
+ * The strictest action the all-tools `*` category imposes, for tools with no per-input matcher to
36162
+ * narrow it onto (see {@link synthesizeManagedToolFallbackEntries}). `deny` wins over `ask`,
36163
+ * and an all-tools `allow` never forces an entry — there is nothing to fail closed on.
36164
+ */
36165
+ function computeAllToolsFailClosedType(allToolsRules) {
36166
+ if (!allToolsRules) return void 0;
36167
+ const actions = Object.values(allToolsRules);
36168
+ if (actions.some((action) => action === "deny")) return "deny";
36169
+ if (actions.some((action) => action === "ask")) return "ask-user";
36170
+ }
36171
+ /**
36172
+ * Extend the same fail-closed treatment `bash` gets (via {@link bashRulesHonoringAllTools}) to the
36173
+ * other managed tools: one that produced no entries of its own above must not fall back to
36174
+ * AugmentCode's own default just because it has no per-input matcher to narrow the all-tools
36175
+ * restriction onto. A category can be stated yet still emit nothing (e.g. only non-`*` allow/ask
36176
+ * patterns, dropped with a warning), so this checks emitted entries rather than whether the
36177
+ * category was merely present in the source config. A tool whose own rules did emit entries is
36178
+ * left untouched here.
36179
+ */
36180
+ function synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType) {
36181
+ if (allToolsFailClosedType === void 0) return [];
36182
+ return Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).filter(([canonicalName]) => canonicalName !== "bash" && !categoriesWithOwnEntries.has(canonicalName)).map(([, augmentToolName]) => ({
36183
+ toolName: augmentToolName,
36184
+ permission: { type: allToolsFailClosedType }
36185
+ }));
36186
+ }
36187
+ /**
35338
36188
  * Sort AugmentCode tool-permission entries to make the `first-match-wins` semantics safe and predictable.
35339
36189
  *
35340
36190
  * Augment evaluates `toolPermissions` top-to-bottom and stops at the first match. To prevent a
@@ -35429,216 +36279,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
35429
36279
  }
35430
36280
  return { permission };
35431
36281
  }
35432
- /**
35433
- * Collect the canonical rules that govern shell commands, for the adapters
35434
- * whose tool models commands and nothing else.
35435
- *
35436
- * The `bash` category contributes every rule. The all-tools `*` category
35437
- * contributes its **restricting** rules — `deny` and `ask` — because a rule
35438
- * written there covers shell commands too, and dropping it inverts the
35439
- * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
35440
- * an adapter that reads only `bash` auto-approves the very command the file
35441
- * denies.
35442
- *
35443
- * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35444
- * need not be a command at all — `secrets/**` under `*` denies a path — and
35445
- * carrying it in the restricting direction only over-restricts, while carrying
35446
- * it in the permissive direction would grant something the author never said
35447
- * about commands. Both directions therefore fail closed.
35448
- */
35449
- function collectShellCommandRules(permission) {
35450
- const rules = [];
35451
- const foreignRestrictingCategories = [];
35452
- const ignoredAllToolsAllowPatterns = [];
35453
- for (const [category, categoryRules] of Object.entries(permission)) {
35454
- if (category === "bash") {
35455
- for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35456
- pattern,
35457
- action,
35458
- fromAllToolsCategory: false
35459
- });
35460
- continue;
35461
- }
35462
- if (category === "*") {
35463
- for (const [pattern, action] of Object.entries(categoryRules)) {
35464
- if (action === "allow") {
35465
- ignoredAllToolsAllowPatterns.push(pattern);
35466
- continue;
35467
- }
35468
- rules.push({
35469
- pattern,
35470
- action,
35471
- fromAllToolsCategory: true
35472
- });
35473
- }
35474
- continue;
35475
- }
35476
- if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35477
- }
35478
- return {
35479
- rules,
35480
- foreignRestrictingCategories,
35481
- ignoredAllToolsAllowPatterns
35482
- };
35483
- }
35484
- /**
35485
- * Build the test an adapter applies to an `allow` pattern before writing it:
35486
- * which restrictions it cannot write name some of the same commands? The
35487
- * answer is the list of those restrictions — empty when the `allow` may be
35488
- * written — so a caller can report both the allow rules it withheld and the
35489
- * restrictions that withheld nothing.
35490
- *
35491
- * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35492
- * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35493
- * asking whether any one command matches both. Width does not enter into it: an
35494
- * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35495
- * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35496
- * on every `git ... --force` command even though neither pattern covers the
35497
- * other's spelling. Comparing only identical spellings would let the most
35498
- * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35499
- *
35500
- * Identical spellings are still compared as strings first, as a shortcut past
35501
- * the walk for the commonest case.
35502
- *
35503
- * `normalizePattern` rewrites a pattern written in the tool's own language into
35504
- * the widest glob it could stand for, for a tool whose patterns are not globs.
35505
- * It reaches the `bash` rules and the `allow` rules, which is where such a
35506
- * pattern is written; an all-tools `*` pattern is canonical — it is read by
35507
- * every tool, so it is a glob already — and is compared as it stands. The
35508
- * rewrite must only ever widen what a pattern covers, so an inexact reading
35509
- * withholds an allow rather than writing one the config restricts — see
35510
- * `warpCommandPatternToGlob`.
35511
- */
35512
- function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35513
- const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35514
- pattern,
35515
- glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35516
- }));
35517
- return (allowPattern) => {
35518
- if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35519
- const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35520
- return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35521
- };
35522
- }
35523
- /**
35524
- * Which of the given all-tools `*` restrictions look like they may not name a
35525
- * command at all — the question a `deny` and an `ask` written there both raise.
35526
- *
35527
- * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35528
- * rules has nothing to withhold, and a pattern the author also wrote under
35529
- * `bash` is a command on their own word. Both are excluded, so what remains is
35530
- * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35531
- * is claimed as a command nowhere else — the shape `secrets/**` has.
35532
- *
35533
- * A `bash` restriction never belongs here: it names a command by construction,
35534
- * so overlapping no allow rule says nothing is wrong with it.
35535
- */
35536
- function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35537
- if (!rules.some(({ action }) => action === "allow")) return [];
35538
- const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35539
- return (0, es_toolkit.uniq)(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35540
- }
35541
- /**
35542
- * Split shell-command rules into the allow and deny lists of a tool that models
35543
- * commands with those two tiers and nothing else.
35544
- *
35545
- * `ask` has no list of its own — such a tool already prompts for whatever it
35546
- * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35547
- * still has to *withhold* the `allow` rules it covers, though: the canonical
35548
- * order is `deny > ask > allow`, so auto-approving a command the file also asks
35549
- * about would answer the prompt the author wanted.
35550
- *
35551
- * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35552
- * from the all-tools `*` category. Warp's cannot: it matches commands with
35553
- * regular expressions rather than globs, and writing any denylist **replaces**
35554
- * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35555
- * the tool's own protection for a rule that matches no command. Where the deny
35556
- * cannot be written it withholds the allow rules it covers instead, which
35557
- * restricts in the same direction without touching the denylist.
35558
- *
35559
- * A `bash` deny withholds nothing: it names a command by construction, so the
35560
- * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35561
- * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35562
- * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35563
- * even where it is written: a pattern under `*` need not name a command —
35564
- * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35565
- * at all, and leaving an overlapping allow beside it would auto-approve the very
35566
- * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35567
- * command pattern is reported; failing open would not be.
35568
- *
35569
- * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35570
- * patterns are not globs.
35571
- */
35572
- function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35573
- const deny = [];
35574
- const unwrittenDenyPatterns = [];
35575
- const restrictions = [];
35576
- const writtenAllToolsDenyPatterns = [];
35577
- const allToolsAskPatterns = [];
35578
- for (const rule of rules) {
35579
- const { pattern, action, fromAllToolsCategory } = rule;
35580
- if (action === "allow") continue;
35581
- if (action !== "deny") {
35582
- restrictions.push(rule);
35583
- if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35584
- continue;
35585
- }
35586
- if (writesAllToolsDeny || !fromAllToolsCategory) {
35587
- deny.push(pattern);
35588
- if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35589
- } else unwrittenDenyPatterns.push(pattern);
35590
- if (fromAllToolsCategory) restrictions.push(rule);
35591
- }
35592
- const budget = createIntersectionBudget();
35593
- const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35594
- normalizePattern,
35595
- budget
35596
- });
35597
- const allow = [];
35598
- const shadowedAllowPatterns = [];
35599
- const withholdingPatterns = /* @__PURE__ */ new Set();
35600
- for (const { pattern, action } of rules) {
35601
- if (action !== "allow") continue;
35602
- const shadowing = shadowingRestrictions(pattern);
35603
- if (shadowing.length > 0) {
35604
- shadowedAllowPatterns.push(pattern);
35605
- for (const restriction of shadowing) withholdingPatterns.add(restriction);
35606
- continue;
35607
- }
35608
- allow.push(pattern);
35609
- }
35610
- return {
35611
- allow,
35612
- deny,
35613
- shadowedAllowPatterns,
35614
- unwrittenDenyPatterns,
35615
- unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35616
- rules,
35617
- allToolsPatterns: writtenAllToolsDenyPatterns,
35618
- withholdingPatterns
35619
- }),
35620
- unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35621
- rules,
35622
- allToolsPatterns: allToolsAskPatterns,
35623
- withholdingPatterns
35624
- }),
35625
- intersectionBudgetExhausted: budget.remaining === 0
35626
- };
35627
- }
35628
- /**
35629
- * Report, for one command-only tool, every canonical rule its two lists could
35630
- * not carry. Every command-only adapter shares this reporting, so a rule
35631
- * dropped in one is worded the same way in all.
35632
- */
35633
- function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35634
- if (intersectionBudgetExhausted) warnWithFallback(logger, `${toolLabel} reached the limit on how much work one generation may spend comparing .rulesync/permissions.jsonc's allow rules against its deny and ask rules, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than the file asks for. Write fewer or shorter command patterns to have them all compared.`);
35635
- for (const category of foreignRestrictingCategories) warnWithFallback(logger, `${toolLabel} only models shell-command permissions (${surfaceLabel}); '${category}' deny and ask rules cannot be represented and were skipped.`);
35636
- if (unwrittenDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} did not write the all-tools '*' deny rule(s) for ${unwrittenDenyPatterns.join(", ")} into its denylist.${unwrittenDenyReason === void 0 ? "" : ` ${unwrittenDenyReason}`} They restrict only by withholding the allow rules they cover; write them under 'bash' to have them enforced as commands.`);
35637
- if (unenforcedAllToolsDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} wrote the all-tools '*' deny rule(s) for ${unenforcedAllToolsDenyPatterns.join(", ")} into its denylist as they stand, but they withheld none of the allow rules beside them. A pattern written under '*' need not name a command — 'secrets/**' there denies a path — and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern.`);
35638
- if (unenforcedAllToolsAskPatterns.length > 0) warnWithFallback(logger, `${toolLabel} has no ask tier (${surfaceLabel}), so the all-tools '*' ask rule(s) for ${unenforcedAllToolsAskPatterns.join(", ")} restrict only by withholding the allow rules they cover — and they covered none. A pattern written under '*' need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns.`);
35639
- if (ignoredAllToolsAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} reads the all-tools '*' category for its deny and ask rules only, so the allow rule(s) for ${ignoredAllToolsAllowPatterns.join(", ")} were skipped — a pattern written under '*' need not be a command. Write them under 'bash' to auto-approve them as commands.`);
35640
- if (shadowedAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} was not given the allow rule(s) for ${shadowedAllowPatterns.join(", ")} because .rulesync/permissions.jsonc restricts the same commands elsewhere, and the stricter rule wins whatever its width.`);
35641
- }
35642
36282
  //#endregion
35643
36283
  //#region src/features/permissions/claudecode-permissions.ts
35644
36284
  /**
@@ -37400,7 +38040,7 @@ function mergeFilesystemCategoryRules({ categoryRules, logger }) {
37400
38040
  return merged;
37401
38041
  }
37402
38042
  function buildCodexBashRulesContent(config) {
37403
- const bashRules = config.permission.bash ?? {};
38043
+ const bashRules = bashRulesHonoringAllTools(config.permission);
37404
38044
  const entries = Object.entries(bashRules);
37405
38045
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
37406
38046
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
@@ -38091,7 +38731,7 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
38091
38731
  function convertRulesyncToCursorPermissions(config, logger) {
38092
38732
  const allow = [];
38093
38733
  const deny = [];
38094
- for (const [category, rules] of Object.entries(config.permission)) {
38734
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38095
38735
  const cursorType = toCursorType(category);
38096
38736
  for (const [pattern, action] of Object.entries(rules)) {
38097
38737
  const entry = buildCursorPermissionEntry(cursorType, toCursorPattern(category, pattern));
@@ -38910,7 +39550,7 @@ function convertRulesyncToDevinPermissions(config) {
38910
39550
  const allow = [];
38911
39551
  const ask = [];
38912
39552
  const deny = [];
38913
- for (const [category, rules] of Object.entries(config.permission)) {
39553
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38914
39554
  const scope = toDevinScope(category);
38915
39555
  for (const [pattern, action] of Object.entries(rules)) {
38916
39556
  const entry = buildDevinPermissionEntry(scope, pattern);
@@ -39198,6 +39838,19 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
39198
39838
  isDeletable() {
39199
39839
  return false;
39200
39840
  }
39841
+ /**
39842
+ * `permission.yaml` is Goose's file, not one rulesync owns: rulesync merges
39843
+ * into it when it exists but has no business bringing it into existence to
39844
+ * hold nothing. When no rule maps, the `user` block holds three empty lists,
39845
+ * which would otherwise be written as a fresh permission.yaml that says
39846
+ * nothing — an absent file and empty lists both mean "no user override, so
39847
+ * Goose decides on its own". An existing file is still rewritten as before,
39848
+ * so user content is never dropped — the skip only applies when there is no
39849
+ * file yet.
39850
+ */
39851
+ shouldSkipCreationWhenPayloadEmpty() {
39852
+ return true;
39853
+ }
39201
39854
  static getSettablePaths(_options) {
39202
39855
  return {
39203
39856
  relativeDirPath: GOOSE_GLOBAL_DIR,
@@ -39282,7 +39935,7 @@ function convertRulesyncToGoosePermissionConfig({ config, logger }) {
39282
39935
  never_allow: []
39283
39936
  };
39284
39937
  const assigned = /* @__PURE__ */ new Map();
39285
- const orderedEntries = Object.entries(config.permission).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
39938
+ const orderedEntries = Object.entries(honorAllToolsOnBash(config.permission)).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
39286
39939
  for (const [category, rules] of orderedEntries) {
39287
39940
  const toolName = RULESYNC_TO_GOOSE_TOOL_NAME[category] ?? category;
39288
39941
  for (const [pattern, action] of Object.entries(rules)) {
@@ -39614,7 +40267,7 @@ function unmanagedEntries(existingPermission, key) {
39614
40267
  */
39615
40268
  function buildGrokPermissionArrays(config, existingPermission, logger) {
39616
40269
  const ranked = /* @__PURE__ */ new Map();
39617
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
40270
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
39618
40271
  const entry = buildGrokEntry(category, pattern);
39619
40272
  if (entry === null) {
39620
40273
  if (action === "deny" && logger) logger.warn(`Grok CLI has no permission tool for the '${category}' category; its 'deny' rule could not be represented and was skipped.`);
@@ -39703,6 +40356,8 @@ function deriveGrokPermissionMode(config) {
39703
40356
  function patternsByAction(category, action) {
39704
40357
  return Object.entries(category ?? {}).filter(([, value]) => value === action).map(([pattern]) => pattern);
39705
40358
  }
40359
+ /** The canonical category whose `deny` rules feed `security.website_blocklist`. */
40360
+ const WEBFETCH_PERMISSION_CATEGORY = "webfetch";
39706
40361
  function clonePermissionBlock(permission) {
39707
40362
  return Object.fromEntries(Object.entries(permission).map(([category, rules]) => [category, { ...rules }]));
39708
40363
  }
@@ -39715,18 +40370,39 @@ function ensureCategory(permission, category) {
39715
40370
  function removeEmptyCategories(permission) {
39716
40371
  for (const [category, rules] of Object.entries(permission)) if (Object.keys(rules).length === 0) delete permission[category];
39717
40372
  }
40373
+ /**
40374
+ * Make the native `command_allowlist` authoritative for the `bash` allow rules
40375
+ * it can speak for, and for those only. The allowlist is a list of shell-command
40376
+ * patterns, so it is generated from `bash` alone (see `fromRulesyncPermissions`);
40377
+ * an allow in any other category names something Hermes's allowlist cannot
40378
+ * carry, so its absence from the list says nothing about it and it is kept as
40379
+ * provenance wrote it. The same holds for a `bash` allow the generator withheld
40380
+ * because a stricter `*` or `bash` rule covers it: the allowlist never carried
40381
+ * it, so its absence is not a retraction and the rule is kept too.
40382
+ */
39718
40383
  function reconcileCommandAllowlist({ permission, commandAllowlist }) {
39719
- const nativeAllows = new Set(commandAllowlist);
39720
- const existingAllowCategories = /* @__PURE__ */ new Map();
39721
- for (const [category, rules] of Object.entries(permission)) for (const [pattern, action] of Object.entries(rules)) {
39722
- if (action !== "allow") continue;
39723
- existingAllowCategories.set(pattern, category);
39724
- if (!nativeAllows.has(pattern)) delete rules[pattern];
39725
- }
39726
- for (const pattern of nativeAllows) {
39727
- const existingCategory = existingAllowCategories.get(pattern);
39728
- if (existingCategory) ensureCategory(permission, existingCategory)[pattern] = "allow";
39729
- else ensureCategory(permission, "bash")[pattern] = "allow";
40384
+ const { rules: commandRules } = collectShellCommandRules(permission);
40385
+ const { shadowedAllowPatterns } = partitionCommandRules({
40386
+ rules: commandRules,
40387
+ writesAllToolsDeny: false
40388
+ });
40389
+ const withheld = new Set(shadowedAllowPatterns);
40390
+ const rules = ensureCategory(permission, SHELL_PERMISSION_CATEGORY);
40391
+ for (const [pattern, action] of Object.entries(rules)) if (action === "allow" && !withheld.has(pattern)) delete rules[pattern];
40392
+ for (const pattern of commandAllowlist) rules[pattern] = "allow";
40393
+ }
40394
+ /**
40395
+ * Report the restricting rules Hermes has no per-pattern primitive for: a
40396
+ * `deny` or `ask` in any category other than `bash`, `*`, and `webfetch`, and
40397
+ * an `ask` under `webfetch` — the blocklist carries a `webfetch` deny but has
40398
+ * no ask tier. (`bash` and `*` are reported by `warnAboutUnwrittenCommandRules`.)
40399
+ * Such rules survive only in the round-trip blob.
40400
+ */
40401
+ function warnAboutUnexpressedHermesRestrictions({ permissionBlock, foreignRestrictingCategories, logger }) {
40402
+ for (const category of foreignRestrictingCategories) {
40403
+ const isWebfetch = category === WEBFETCH_PERMISSION_CATEGORY;
40404
+ if (isWebfetch && patternsByAction(permissionBlock[category], "ask").length === 0) continue;
40405
+ warnWithFallback(logger, isWebfetch ? "Hermes Agent's security.website_blocklist has no ask tier, so the 'webfetch' ask rule(s) cannot be represented and were skipped; they survive only in the permissions.rulesync round-trip block." : `Hermes Agent has no per-pattern primitive for '${category}' deny and ask rules (it enforces command_allowlist, approvals.deny, and security.website_blocklist), so they were skipped; they survive only in the permissions.rulesync round-trip block.`);
39730
40406
  }
39731
40407
  }
39732
40408
  function reconcileNativeDenies({ permission, category, patterns }) {
@@ -39838,14 +40514,14 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39838
40514
  const approvals = isRecord$1(config.approvals) ? config.approvals : {};
39839
40515
  reconcileNativeDenies({
39840
40516
  permission,
39841
- category: "bash",
40517
+ category: SHELL_PERMISSION_CATEGORY,
39842
40518
  patterns: isStringArray$2(approvals.deny) ? approvals.deny : []
39843
40519
  });
39844
40520
  const security = isRecord$1(config.security) ? config.security : {};
39845
40521
  const websiteBlocklist = isRecord$1(security.website_blocklist) ? security.website_blocklist : {};
39846
40522
  reconcileNativeDenies({
39847
40523
  permission,
39848
- category: "webfetch",
40524
+ category: WEBFETCH_PERMISSION_CATEGORY,
39849
40525
  patterns: websiteBlocklist.enabled === true && isStringArray$2(websiteBlocklist.domains) ? websiteBlocklist.domains : []
39850
40526
  });
39851
40527
  removeEmptyCategories(permission);
@@ -39865,12 +40541,32 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39865
40541
  fileContent: JSON.stringify(imported, null, 2)
39866
40542
  });
39867
40543
  }
39868
- static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
40544
+ static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false, logger }) {
39869
40545
  const permissions = rulesyncPermissions.getJson();
39870
40546
  const permissionBlock = permissions.permission ?? {};
39871
- const commandAllowlist = Object.entries(permissionBlock).flatMap(([, patterns]) => patternsByAction(patterns, "allow"));
39872
- const bashDeny = patternsByAction(permissionBlock.bash, "deny");
39873
- const webfetchDeny = patternsByAction(permissionBlock.webfetch, "deny");
40547
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permissionBlock);
40548
+ const { allow: commandAllowlist, deny: bashDeny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
40549
+ rules,
40550
+ writesAllToolsDeny: false
40551
+ });
40552
+ warnAboutUnexpressedHermesRestrictions({
40553
+ permissionBlock,
40554
+ foreignRestrictingCategories,
40555
+ logger
40556
+ });
40557
+ warnAboutUnwrittenCommandRules({
40558
+ toolLabel: "Hermes Agent",
40559
+ surfaceLabel: "command_allowlist/approvals.deny",
40560
+ foreignRestrictingCategories: [],
40561
+ shadowedAllowPatterns,
40562
+ unwrittenDenyPatterns,
40563
+ unwrittenDenyReason: "approvals.deny is a hard denylist of shell commands, and a pattern written under '*' need not be a command at all.",
40564
+ unenforcedAllToolsAskPatterns,
40565
+ ignoredAllToolsAllowPatterns,
40566
+ intersectionBudgetExhausted,
40567
+ logger
40568
+ });
40569
+ const webfetchDeny = patternsByAction(permissionBlock[WEBFETCH_PERMISSION_CATEGORY], "deny");
39874
40570
  let config = {};
39875
40571
  if (commandAllowlist.length > 0) config.command_allowlist = commandAllowlist;
39876
40572
  if (bashDeny.length > 0) config.approvals = { deny: bashDeny };
@@ -40137,7 +40833,7 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
40137
40833
  */
40138
40834
  function convertRulesyncToJunieRules({ config, logger, existingRules, overrideSecretFile, overrideRuleDefaults }) {
40139
40835
  const ruleLists = {};
40140
- for (const [category, patterns] of Object.entries(config.permission)) {
40836
+ for (const [category, patterns] of Object.entries(honorAllToolsOnBash(config.permission))) {
40141
40837
  const group = CANONICAL_TO_JUNIE_GROUP[category];
40142
40838
  if (!group) {
40143
40839
  if (Object.keys(patterns).length > 0) logger?.warn(`Junie allowlist only models executables/fileEditing/mcpTools/readOutsideProject (canonical bash/edit/write/read/mcp); '${category}' rules cannot be represented and were skipped.`);
@@ -40387,7 +41083,7 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
40387
41083
  const rulesyncJson = rulesyncPermissions.getJson();
40388
41084
  const kiloOverride = rulesyncJson.kilo;
40389
41085
  const incomingPermission = {
40390
- ...rulesyncJson.permission,
41086
+ ...honorAllToolsOnBash(rulesyncJson.permission),
40391
41087
  ...kiloOverride?.permission
40392
41088
  };
40393
41089
  const droppedDenyByKey = {};
@@ -40906,7 +41602,7 @@ function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
40906
41602
  allowedCommands: [],
40907
41603
  deniedCommands: []
40908
41604
  };
40909
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
41605
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
40910
41606
  if (action === "ask") {
40911
41607
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
40912
41608
  continue;
@@ -41190,7 +41886,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
41190
41886
  const rulesyncJson = rulesyncPermissions.getJson();
41191
41887
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
41192
41888
  const sharedPermission = {};
41193
- for (const [category, value] of Object.entries(rulesyncJson.permission ?? {})) sharedPermission[toOpencodePermissionKey(category)] = value;
41889
+ for (const [category, value] of Object.entries(honorAllToolsOnBash(rulesyncJson.permission ?? {}))) sharedPermission[toOpencodePermissionKey(category)] = value;
41194
41890
  const permission = {};
41195
41891
  for (const [category, value] of Object.entries({
41196
41892
  ...sharedPermission,
@@ -42319,11 +43015,19 @@ var RooPermissions = class extends ToolPermissions {
42319
43015
  const paths = this.getSettablePaths();
42320
43016
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
42321
43017
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
42322
- const rules = rulesyncPermissions.getJson().permission[COMMAND_CATEGORY];
43018
+ const permission = rulesyncPermissions.getJson().permission;
43019
+ const bashStated = permission[COMMAND_CATEGORY] !== void 0;
42323
43020
  const patch = {};
42324
- if (rules !== void 0) {
43021
+ if (bashStated) {
43022
+ const { bash } = resolveShellCommandLists({
43023
+ permission,
43024
+ writesAllToolsDeny: true,
43025
+ toolLabel: this.getToolLabel(),
43026
+ surfaceLabel: `${this.getAllowedCommandsKey()}/${this.getDeniedCommandsKey()}`,
43027
+ logger
43028
+ });
42325
43029
  const { allowed, denied } = buildVscodeCommandLists({
42326
- rules,
43030
+ rules: bash,
42327
43031
  toolLabel: this.getToolLabel(),
42328
43032
  logger
42329
43033
  });
@@ -42334,7 +43038,7 @@ var RooPermissions = class extends ToolPermissions {
42334
43038
  outputRoot,
42335
43039
  relativeDirPath: paths.relativeDirPath,
42336
43040
  relativeFilePath: paths.relativeFilePath,
42337
- ownsCommandKeys: rules !== void 0,
43041
+ ownsCommandKeys: bashStated,
42338
43042
  fileContent: applySharedConfigPatch({
42339
43043
  fileKey: sharedConfigFileKey(paths),
42340
43044
  feature: "permissions",
@@ -42714,7 +43418,7 @@ function convertRulesyncToRovodevToolPermissions({ config, logger }) {
42714
43418
  config,
42715
43419
  logger
42716
43420
  });
42717
- for (const [category, rules] of Object.entries(config.permission)) {
43421
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42718
43422
  if (category === CATCH_ALL_PATTERN$1) {
42719
43423
  const toolWideDefault = convertAllToolsRules({
42720
43424
  rules,
@@ -43724,7 +44428,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
43724
44428
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43725
44429
  const existingContent = await readFileContentOrNull(filePath) ?? "";
43726
44430
  const config = parseVibeConfig(existingContent);
43727
- const permission = rulesyncPermissions.getJson().permission;
44431
+ const permission = honorAllToolsOnBash(rulesyncPermissions.getJson().permission);
43728
44432
  const vibeOverride = rulesyncPermissions.getJson().vibe;
43729
44433
  const tools = toVibeToolsRecord(config.tools);
43730
44434
  const diskShellPatterns = new Map([VIBE_SHELL_CATEGORY, ...VIBE_SHELL_ALIAS_TOOL_NAMES].map((vibeToolName) => [vibeToolName, toStringArray(readVibeToolConfig({
@@ -45065,6 +45769,19 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
45065
45769
  isDeletable() {
45066
45770
  return false;
45067
45771
  }
45772
+ /**
45773
+ * `settings.toml` is Warp's file, not one rulesync owns: rulesync merges into
45774
+ * it when it exists but has no business bringing it into existence to hold
45775
+ * nothing. When no rule maps, both command lists are dropped and the payload
45776
+ * is a bare `[agents.profiles]` table, which would otherwise be written as a
45777
+ * fresh settings file that says nothing — an absent file and an empty table
45778
+ * both mean "Warp's own defaults". An existing file is still rewritten as
45779
+ * before, so user content is never dropped — the skip only applies when
45780
+ * there is no file yet.
45781
+ */
45782
+ shouldSkipCreationWhenPayloadEmpty() {
45783
+ return true;
45784
+ }
45068
45785
  static getSettablePaths(_options) {
45069
45786
  return {
45070
45787
  relativeDirPath: warpSettingsDir(),
@@ -45676,6 +46393,7 @@ function buildZedToolPermissions({ permission, logger }) {
45676
46393
  for (const [category, rules] of Object.entries(permission)) {
45677
46394
  if (category === "*") {
45678
46395
  for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
46396
+ else if (permission.bash?.[pattern] === "deny" || permission.bash?.[pattern] === "ask") continue;
45679
46397
  else logger?.warn(`Zed permissions: dropping the "*" category rule for pattern "${pattern}" — Zed's global tool-permission default takes no patterns; scope the rule to a tool category instead.`);
45680
46398
  continue;
45681
46399
  }
@@ -45830,7 +46548,7 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
45830
46548
  const toolPermissions = asRecord(agent.tool_permissions);
45831
46549
  const existingTools = asRecord(toolPermissions.tools);
45832
46550
  const { managedDefault, managedTools, excludedCategories, inertMcpCategories } = buildZedToolPermissions({
45833
- permission: config.permission,
46551
+ permission: honorAllToolsOnBash(config.permission),
45834
46552
  logger
45835
46553
  });
45836
46554
  if (excludedCategories.length > 0) logger?.warn(`Zed permissions: dropping the ${excludedCategories.map((category) => `"${category}"`).join(", ")} ${excludedCategories.length === 1 ? "category" : "categories"} — Zed does not gate its read-only tools, so the entries would never be consulted. Zed's read-denial surface is \`private_files\`, which the ignore feature writes from \`.rulesync/.aiignore\`.`);
@@ -46339,6 +47057,24 @@ var PermissionsProcessor = class extends FeatureProcessor {
46339
47057
  }
46340
47058
  };
46341
47059
  //#endregion
47060
+ //#region src/constants/codebuddy-paths.ts
47061
+ /**
47062
+ * CodeBuddy Code configuration-layout conventions.
47063
+ *
47064
+ * CodeBuddy Code (`@tencent-ai/codebuddy-code`) is Tencent Cloud's terminal
47065
+ * coding agent. Its configuration surface mirrors Claude Code closely: a
47066
+ * root memory file plus a `.codebuddy/` tree.
47067
+ *
47068
+ * @see https://www.codebuddy.ai/docs/cli/memory
47069
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
47070
+ */
47071
+ /** Root directory for CodeBuddy Code configuration, relative to the scope root. */
47072
+ const CODEBUDDY_DIR = ".codebuddy";
47073
+ const CODEBUDDY_RULE_FILE_NAME = "CODEBUDDY.md";
47074
+ const CODEBUDDY_LOCAL_RULE_FILE_NAME = "CODEBUDDY.local.md";
47075
+ /** Modular rules directory name under `.codebuddy/`. */
47076
+ const CODEBUDDY_RULES_DIR_NAME = "rules";
47077
+ //#endregion
46342
47078
  //#region src/features/skills/simulated-skill.ts
46343
47079
  const SimulatedSkillFrontmatterSchema = zod_mini.z.looseObject({
46344
47080
  name: zod_mini.z.string(),
@@ -46877,7 +47613,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46877
47613
  * This only deletes directories that are no longer in the rulesync source, not directories that will be overwritten.
46878
47614
  */
46879
47615
  async removeOrphanAiDirs(existingDirs, generatedDirs) {
46880
- const generatedPaths = new Set(generatedDirs.map((d) => d.getDirPath()));
47616
+ const generatedPaths = new Set(generatedDirs.map((d) => caseFoldIdentity(d.getDirPath())));
46881
47617
  const orphanPaths = /* @__PURE__ */ new Set();
46882
47618
  const quotedOutputRoot = quoteForLog(this.outputRoot);
46883
47619
  for (const aiDir of existingDirs) {
@@ -46902,7 +47638,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46902
47638
  this.logger.warn(verdict === "equal" ? `Refusing to delete ${quotedDirPath}: it is the root it was found in, not a directory inside that root` : `Refusing to delete ${quotedDirPath}: it is not inside ${quotedRoot}, the root it was found in`);
46903
47639
  continue;
46904
47640
  }
46905
- if (!generatedPaths.has(dirPath)) orphanPaths.add(dirPath);
47641
+ if (!generatedPaths.has(caseFoldIdentity(dirPath))) orphanPaths.add(dirPath);
46906
47642
  }
46907
47643
  return await this.deleteOrphanPaths({
46908
47644
  paths: orphanPaths,
@@ -47001,7 +47737,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47001
47737
  const mainFile = aiDir.getMainFile();
47002
47738
  if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
47003
47739
  for (const file of aiDir.getOtherFiles()) generatedNames.add(toPosixPath(file.relativeFilePathToDirPath));
47004
- const generatedNamesFolded = new Set([...generatedNames].map((name) => name.toLowerCase()));
47740
+ const generatedNamesFolded = new Set([...generatedNames].map((name) => caseFoldIdentity(name)));
47005
47741
  let existingNames;
47006
47742
  try {
47007
47743
  existingNames = await listFilePathsRecursively(dirPath, {
@@ -47016,7 +47752,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47016
47752
  const posixName = toPosixPath(existingName);
47017
47753
  if (generatedNames.has(posixName)) continue;
47018
47754
  const filePath = (0, node_path.join)(dirPath, existingName);
47019
- if (generatedNamesFolded.has(posixName.toLowerCase())) {
47755
+ if (generatedNamesFolded.has(caseFoldIdentity(posixName))) {
47020
47756
  this.logger.warn(`Refusing to delete ${quoteForLog(filePath)}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
47021
47757
  continue;
47022
47758
  }
@@ -47072,7 +47808,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47072
47808
  generatedPaths.add(flatFilePath);
47073
47809
  for (const file of generatedDir.getOtherFiles()) generatedPaths.add((0, node_path.join)(generatedDirPath, file.relativeFilePathToDirPath));
47074
47810
  }
47075
- const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => generatedPath.toLowerCase()));
47811
+ const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => caseFoldIdentity(generatedPath)));
47076
47812
  const orphanPaths = /* @__PURE__ */ new Set();
47077
47813
  const quotedOutputRoot = quoteForLog(this.outputRoot);
47078
47814
  for (const aiDir of existingFlatFiles) {
@@ -47102,7 +47838,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47102
47838
  continue;
47103
47839
  }
47104
47840
  if (generatedPaths.has(filePath)) continue;
47105
- if (generatedPathsFolded.has(filePath.toLowerCase())) {
47841
+ if (generatedPathsFolded.has(caseFoldIdentity(filePath))) {
47106
47842
  this.logger.warn(`Refusing to delete ${quotedFilePath}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
47107
47843
  continue;
47108
47844
  }
@@ -48934,6 +49670,191 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
48934
49670
  }
48935
49671
  };
48936
49672
  //#endregion
49673
+ //#region src/features/skills/crush-skill.ts
49674
+ const CrushSkillFrontmatterSchema = zod_mini.z.looseObject({
49675
+ name: zod_mini.z.string(),
49676
+ description: zod_mini.z.string(),
49677
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
49678
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
49679
+ license: zod_mini.z.optional(zod_mini.z.string()),
49680
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
49681
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
49682
+ });
49683
+ /**
49684
+ * Represents a Crush Agent Skill directory.
49685
+ *
49686
+ * Crush auto-discovers Agent Skills (`SKILL.md` per directory) from
49687
+ * `.crush/skills/` at project scope and `~/.config/crush/skills/` (or
49688
+ * `$CRUSH_SKILLS_DIR`) at global scope. Unless `$CRUSH_SKILLS_DIR` is set,
49689
+ * Crush also scans several shared directories it does not own (globally
49690
+ * `~/.config/agents/skills/`, `~/.agents/skills/`, `~/.claude/skills/`;
49691
+ * per-project `.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, also
49692
+ * checked at a git worktree's common root); this class writes only to the
49693
+ * Crush-specific path above, leaving those shared roots to their own targets.
49694
+ *
49695
+ * Crush's `UserInvocable` field is a non-pointer Go `bool`, so an omitted
49696
+ * `user-invocable` (at both the root and the `crush:` section) resolves to
49697
+ * `false`: the skill stays reachable by the model but is hidden from Crush's
49698
+ * command palette. See `FromSkillCatalog` in `internal/commands/commands.go`.
49699
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
49700
+ */
49701
+ var CrushSkill = class CrushSkill extends ToolSkill {
49702
+ constructor({ outputRoot = process.cwd(), relativeDirPath = CRUSH_SKILLS_PROJECT_DIR, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
49703
+ super({
49704
+ outputRoot,
49705
+ relativeDirPath,
49706
+ dirName,
49707
+ mainFile: {
49708
+ name: SKILL_FILE_NAME,
49709
+ body,
49710
+ frontmatter: { ...frontmatter }
49711
+ },
49712
+ otherFiles,
49713
+ global
49714
+ });
49715
+ if (validate) {
49716
+ const result = this.validate();
49717
+ if (!result.success) throw result.error;
49718
+ }
49719
+ }
49720
+ static getSettablePaths({ global = false } = {}) {
49721
+ return { relativeDirPath: global ? CRUSH_SKILLS_GLOBAL_DIR : CRUSH_SKILLS_PROJECT_DIR };
49722
+ }
49723
+ getFrontmatter() {
49724
+ return CrushSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
49725
+ }
49726
+ getBody() {
49727
+ return this.mainFile?.body ?? "";
49728
+ }
49729
+ validate() {
49730
+ if (!this.mainFile) return {
49731
+ success: false,
49732
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
49733
+ };
49734
+ const result = CrushSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
49735
+ if (!result.success) return {
49736
+ success: false,
49737
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
49738
+ };
49739
+ return {
49740
+ success: true,
49741
+ error: null
49742
+ };
49743
+ }
49744
+ toRulesyncSkill() {
49745
+ const frontmatter = this.getFrontmatter();
49746
+ const crushSection = {
49747
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
49748
+ ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
49749
+ ...frontmatter.license !== void 0 && { license: frontmatter.license },
49750
+ ...frontmatter.compatibility !== void 0 && { compatibility: frontmatter.compatibility },
49751
+ ...frontmatter.metadata !== void 0 && { metadata: frontmatter.metadata }
49752
+ };
49753
+ const rulesyncFrontmatter = {
49754
+ name: frontmatter.name,
49755
+ description: frontmatter.description,
49756
+ targets: ["*"],
49757
+ ...Object.keys(crushSection).length > 0 && { crush: crushSection }
49758
+ };
49759
+ return new RulesyncSkill({
49760
+ outputRoot: this.outputRoot,
49761
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
49762
+ dirName: this.getDirName(),
49763
+ frontmatter: rulesyncFrontmatter,
49764
+ body: this.getBody(),
49765
+ otherFiles: this.getOtherFiles(),
49766
+ validate: true,
49767
+ global: this.global
49768
+ });
49769
+ }
49770
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49771
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49772
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49773
+ const crushSection = rulesyncFrontmatter.crush;
49774
+ const resolvedUserInvocable = resolveUserInvocable({
49775
+ rootFrontmatter: rulesyncFrontmatter,
49776
+ section: crushSection
49777
+ });
49778
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
49779
+ rootFrontmatter: rulesyncFrontmatter,
49780
+ section: crushSection
49781
+ });
49782
+ const license = resolveLicense({
49783
+ rootFrontmatter: rulesyncFrontmatter,
49784
+ section: crushSection
49785
+ });
49786
+ const compatibility = resolveCompatibility({
49787
+ rootFrontmatter: rulesyncFrontmatter,
49788
+ section: crushSection
49789
+ });
49790
+ const metadata = resolveMetadata({
49791
+ rootFrontmatter: rulesyncFrontmatter,
49792
+ section: crushSection
49793
+ });
49794
+ const compatibilityString = compatibility === void 0 ? void 0 : toCompatibilityString(compatibility);
49795
+ const crushFrontmatter = {
49796
+ name: rulesyncFrontmatter.name,
49797
+ description: rulesyncFrontmatter.description,
49798
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
49799
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
49800
+ ...license !== void 0 && { license },
49801
+ ...compatibilityString !== void 0 && compatibilityString.length > 0 && { compatibility: compatibilityString },
49802
+ ...metadata !== void 0 && { metadata: toStringMetadata(metadata) }
49803
+ };
49804
+ return new CrushSkill({
49805
+ outputRoot,
49806
+ relativeDirPath: settablePaths.relativeDirPath,
49807
+ dirName: rulesyncSkill.getDirName(),
49808
+ frontmatter: crushFrontmatter,
49809
+ body: rulesyncSkill.getBody(),
49810
+ otherFiles: rulesyncSkill.getOtherFiles(),
49811
+ validate,
49812
+ global
49813
+ });
49814
+ }
49815
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
49816
+ const targets = rulesyncSkill.getFrontmatter().targets;
49817
+ return targets.includes("*") || targets.includes("crush");
49818
+ }
49819
+ static async fromDir(params) {
49820
+ const loaded = await this.loadSkillDirContent({
49821
+ ...params,
49822
+ getSettablePaths: CrushSkill.getSettablePaths
49823
+ });
49824
+ const result = CrushSkillFrontmatterSchema.safeParse(loaded.frontmatter);
49825
+ if (!result.success) {
49826
+ const skillDirPath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
49827
+ throw new Error(`Invalid frontmatter in ${(0, node_path.join)(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
49828
+ }
49829
+ return new CrushSkill({
49830
+ outputRoot: loaded.outputRoot,
49831
+ relativeDirPath: loaded.relativeDirPath,
49832
+ dirName: loaded.dirName,
49833
+ frontmatter: result.data,
49834
+ body: loaded.body,
49835
+ otherFiles: loaded.otherFiles,
49836
+ validate: true,
49837
+ global: loaded.global
49838
+ });
49839
+ }
49840
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
49841
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49842
+ return new CrushSkill({
49843
+ outputRoot,
49844
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
49845
+ dirName,
49846
+ frontmatter: {
49847
+ name: "",
49848
+ description: ""
49849
+ },
49850
+ body: "",
49851
+ otherFiles: [],
49852
+ validate: false,
49853
+ global
49854
+ });
49855
+ }
49856
+ };
49857
+ //#endregion
48937
49858
  //#region src/features/skills/cursor-skill.ts
48938
49859
  const CursorSkillFrontmatterSchema = zod_mini.z.looseObject({
48939
49860
  name: zod_mini.z.string(),
@@ -49098,9 +50019,9 @@ const DeepagentsSkillFrontmatterSchema = zod_mini.z.looseObject({
49098
50019
  name: zod_mini.z.string(),
49099
50020
  description: zod_mini.z.string(),
49100
50021
  "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
49101
- license: zod_mini.z.optional(zod_mini.z.string()),
49102
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
49103
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
50022
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
50023
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
50024
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
49104
50025
  });
49105
50026
  var DeepagentsSkill = class DeepagentsSkill extends ToolSkill {
49106
50027
  constructor({ outputRoot = process.cwd(), relativeDirPath = DEEPAGENTS_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -50158,9 +51079,9 @@ var JunieSkill = class JunieSkill extends ToolSkill {
50158
51079
  const KiloSkillFrontmatterSchema = zod_mini.z.looseObject({
50159
51080
  name: zod_mini.z.string(),
50160
51081
  description: zod_mini.z.string(),
50161
- license: zod_mini.z.optional(zod_mini.z.string()),
50162
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
50163
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
51082
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
51083
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
51084
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
50164
51085
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
50165
51086
  });
50166
51087
  var KiloSkill = class KiloSkill extends ToolSkill {
@@ -50597,10 +51518,11 @@ var KiroSkill = class KiroSkill extends ToolSkill {
50597
51518
  rootFrontmatter: rulesyncFrontmatter,
50598
51519
  section: kiroSection
50599
51520
  });
51521
+ const { name: _sectionName, description: _sectionDescription, ...section } = kiroSection ?? {};
50600
51522
  const kiroFrontmatter = {
50601
- ...kiroSection,
50602
51523
  name: rulesyncFrontmatter.name,
50603
51524
  description: rulesyncFrontmatter.description,
51525
+ ...section,
50604
51526
  ...license !== void 0 && { license },
50605
51527
  ...compatibility !== void 0 && { compatibility },
50606
51528
  ...metadata !== void 0 && { metadata }
@@ -50832,9 +51754,9 @@ var MusecodeSkill = class MusecodeSkill extends ToolSkill {
50832
51754
  const OpenCodeSkillFrontmatterSchema = zod_mini.z.looseObject({
50833
51755
  name: zod_mini.z.string(),
50834
51756
  description: zod_mini.z.string(),
50835
- license: zod_mini.z.optional(zod_mini.z.string()),
50836
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
50837
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
51757
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
51758
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
51759
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
50838
51760
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
50839
51761
  });
50840
51762
  var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
@@ -52748,6 +53670,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
52748
53670
  supportsGlobal: true
52749
53671
  }
52750
53672
  }],
53673
+ ["crush", {
53674
+ class: CrushSkill,
53675
+ meta: {
53676
+ supportsProject: true,
53677
+ supportsSimulated: false,
53678
+ supportsGlobal: true
53679
+ }
53680
+ }],
52751
53681
  ["cursor", {
52752
53682
  class: CursorSkill,
52753
53683
  meta: {
@@ -60491,6 +61421,238 @@ var ClineRule = class ClineRule extends ToolRule {
60491
61421
  }
60492
61422
  };
60493
61423
  //#endregion
61424
+ //#region src/features/rules/codebuddy-rule.ts
61425
+ /**
61426
+ * Frontmatter schema for CodeBuddy Code modular rules.
61427
+ * @see https://www.codebuddy.ai/docs/cli/memory
61428
+ */
61429
+ const CodebuddyRuleFrontmatterSchema = zod_mini.z.object({
61430
+ description: zod_mini.z.optional(zod_mini.z.string()),
61431
+ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
61432
+ alwaysApply: zod_mini.z.optional(zod_mini.z.boolean())
61433
+ });
61434
+ /**
61435
+ * A universal glob (matching everything) is redundant on an Always Apply
61436
+ * rule and, paired with `alwaysApply: true`, is the same semantic conflict
61437
+ * `CursorRule.resolveCursorGlobs` avoids for Cursor: `alwaysApply` already
61438
+ * applies the rule everywhere, so also emitting an explicit
61439
+ * `paths: ["**\/*"]` is at best redundant and, on a subsequent
61440
+ * import/generate round-trip, misleadingly implies the rule is scoped by
61441
+ * path rather than always-on.
61442
+ */
61443
+ const UNIVERSAL_PATHS = /* @__PURE__ */ new Set(["**/*", "*"]);
61444
+ /**
61445
+ * Rule generator for CodeBuddy Code, Tencent Cloud's terminal coding agent
61446
+ * (`@tencent-ai/codebuddy-code`). Its configuration surface mirrors Claude
61447
+ * Code closely.
61448
+ *
61449
+ * Rules format:
61450
+ * - {project}/CODEBUDDY.md (root: true), also read from {project}/.codebuddy/CODEBUDDY.md
61451
+ * - {project}/.codebuddy/rules/*.md (root: false, with optional
61452
+ * `description` / `paths` / `alwaysApply` frontmatter)
61453
+ * - Global: ~/.codebuddy/CODEBUDDY.md and ~/.codebuddy/rules/*.md
61454
+ *
61455
+ * @see https://www.codebuddy.ai/docs/cli/memory
61456
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
61457
+ */
61458
+ var CodebuddyRule = class CodebuddyRule extends ToolRule {
61459
+ frontmatter;
61460
+ body;
61461
+ static getSettablePaths({ global, excludeToolDir } = {}) {
61462
+ if (global) return {
61463
+ root: {
61464
+ relativeDirPath: buildToolPath(CODEBUDDY_DIR, ".", excludeToolDir),
61465
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61466
+ },
61467
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61468
+ };
61469
+ return {
61470
+ root: {
61471
+ relativeDirPath: ".",
61472
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61473
+ },
61474
+ alternativeRoots: [{
61475
+ relativeDirPath: CODEBUDDY_DIR,
61476
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61477
+ }],
61478
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61479
+ };
61480
+ }
61481
+ constructor({ frontmatter, body, ...rest }) {
61482
+ if (rest.validate) {
61483
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61484
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
61485
+ }
61486
+ super({
61487
+ ...rest,
61488
+ fileContent: rest.root ? body : CodebuddyRule.generateFileContent(body, frontmatter)
61489
+ });
61490
+ this.frontmatter = frontmatter;
61491
+ this.body = body;
61492
+ }
61493
+ static generateFileContent(body, frontmatter) {
61494
+ if (frontmatter.description === void 0 && frontmatter.paths === void 0 && frontmatter.alwaysApply === void 0) return body;
61495
+ return stringifyFrontmatter(body, {
61496
+ description: frontmatter.description,
61497
+ alwaysApply: frontmatter.alwaysApply,
61498
+ paths: frontmatter.paths
61499
+ });
61500
+ }
61501
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false, relativeDirPath: overrideDirPath }) {
61502
+ const paths = this.getSettablePaths({ global });
61503
+ if (relativeFilePath === paths.root.relativeFilePath) {
61504
+ const rootDirPath = overrideDirPath ?? paths.root.relativeDirPath;
61505
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, rootDirPath, paths.root.relativeFilePath));
61506
+ return new CodebuddyRule({
61507
+ outputRoot,
61508
+ relativeDirPath: rootDirPath,
61509
+ relativeFilePath: paths.root.relativeFilePath,
61510
+ frontmatter: {},
61511
+ body: fileContent.trim(),
61512
+ validate,
61513
+ root: true
61514
+ });
61515
+ }
61516
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${relativeFilePath}`);
61517
+ const relativePath = (0, node_path.join)(paths.nonRoot.relativeDirPath, relativeFilePath);
61518
+ const filePath = (0, node_path.join)(outputRoot, relativePath);
61519
+ const { frontmatter, body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
61520
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61521
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
61522
+ return new CodebuddyRule({
61523
+ outputRoot,
61524
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61525
+ relativeFilePath,
61526
+ frontmatter: result.data,
61527
+ body: content.trim(),
61528
+ validate,
61529
+ root: false
61530
+ });
61531
+ }
61532
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61533
+ const isRoot = relativeFilePath === this.getSettablePaths({ global }).root.relativeFilePath;
61534
+ return new CodebuddyRule({
61535
+ outputRoot,
61536
+ relativeDirPath,
61537
+ relativeFilePath,
61538
+ frontmatter: {},
61539
+ body: "",
61540
+ validate: false,
61541
+ root: isRoot
61542
+ });
61543
+ }
61544
+ static resolveCodebuddyPaths({ paths, alwaysApply }) {
61545
+ if (!paths || paths.length === 0) return;
61546
+ if (alwaysApply && paths.every((path) => UNIVERSAL_PATHS.has(path.trim()))) return;
61547
+ return paths;
61548
+ }
61549
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
61550
+ const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
61551
+ const root = rulesyncFrontmatter.root ?? false;
61552
+ const paths = this.getSettablePaths({ global });
61553
+ const body = rulesyncRule.getBody();
61554
+ if (root) return new CodebuddyRule({
61555
+ outputRoot,
61556
+ frontmatter: {},
61557
+ body,
61558
+ relativeDirPath: paths.root.relativeDirPath,
61559
+ relativeFilePath: paths.root.relativeFilePath,
61560
+ validate,
61561
+ root
61562
+ });
61563
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${rulesyncRule.getRelativeFilePath()}`);
61564
+ const codebuddyPaths = rulesyncFrontmatter.codebuddy?.paths;
61565
+ const globs = rulesyncFrontmatter.globs;
61566
+ const alwaysApply = rulesyncFrontmatter.codebuddy?.alwaysApply;
61567
+ const pathsValue = CodebuddyRule.resolveCodebuddyPaths({
61568
+ paths: codebuddyPaths ?? (globs?.length ? globs : void 0),
61569
+ alwaysApply: alwaysApply === true
61570
+ });
61571
+ const codebuddyFrontmatter = {
61572
+ description: rulesyncFrontmatter.codebuddy?.description ?? rulesyncFrontmatter.description,
61573
+ paths: pathsValue,
61574
+ alwaysApply
61575
+ };
61576
+ return new CodebuddyRule({
61577
+ outputRoot,
61578
+ frontmatter: codebuddyFrontmatter,
61579
+ body,
61580
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61581
+ relativeFilePath: rulesyncRule.getRelativeFilePath(),
61582
+ validate,
61583
+ root
61584
+ });
61585
+ }
61586
+ toRulesyncRule() {
61587
+ const targets = ["*"];
61588
+ if (this.isRoot()) {
61589
+ const rulesyncFrontmatter = {
61590
+ targets,
61591
+ root: true,
61592
+ description: this.description,
61593
+ globs: ["**/*"]
61594
+ };
61595
+ return new RulesyncRule({
61596
+ outputRoot: this.getOutputRoot(),
61597
+ frontmatter: rulesyncFrontmatter,
61598
+ body: this.body,
61599
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61600
+ relativeFilePath: this.getRelativeFilePath(),
61601
+ validate: true
61602
+ });
61603
+ }
61604
+ const isAlways = this.frontmatter.alwaysApply === true;
61605
+ const sourcePaths = this.frontmatter.paths ?? [];
61606
+ const globs = sourcePaths.length === 0 && isAlways ? ["**/*"] : sourcePaths;
61607
+ const rulesyncFrontmatter = {
61608
+ targets,
61609
+ root: false,
61610
+ description: this.frontmatter.description,
61611
+ globs,
61612
+ ...(this.frontmatter.paths !== void 0 || this.frontmatter.alwaysApply !== void 0 || this.frontmatter.description !== void 0) && { codebuddy: {
61613
+ paths: this.frontmatter.paths,
61614
+ alwaysApply: this.frontmatter.alwaysApply,
61615
+ description: this.frontmatter.description
61616
+ } }
61617
+ };
61618
+ return new RulesyncRule({
61619
+ outputRoot: this.getOutputRoot(),
61620
+ frontmatter: rulesyncFrontmatter,
61621
+ body: this.body,
61622
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61623
+ relativeFilePath: this.getRelativeFilePath(),
61624
+ validate: true
61625
+ });
61626
+ }
61627
+ validate() {
61628
+ if (!this.frontmatter) return {
61629
+ success: true,
61630
+ error: null
61631
+ };
61632
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(this.frontmatter);
61633
+ if (result.success) return {
61634
+ success: true,
61635
+ error: null
61636
+ };
61637
+ else return {
61638
+ success: false,
61639
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
61640
+ };
61641
+ }
61642
+ getFrontmatter() {
61643
+ return this.frontmatter;
61644
+ }
61645
+ getBody() {
61646
+ return this.body;
61647
+ }
61648
+ static isTargetedByRulesyncRule(rulesyncRule) {
61649
+ return this.isTargetedByRulesyncRuleDefault({
61650
+ rulesyncRule,
61651
+ toolTarget: "codebuddy"
61652
+ });
61653
+ }
61654
+ };
61655
+ //#endregion
60494
61656
  //#region src/features/rules/codexcli-rule.ts
60495
61657
  var CodexcliRule = class CodexcliRule extends ToolRule {
60496
61658
  constructor({ fileContent, root, ...rest }) {
@@ -60787,6 +61949,79 @@ var CopilotcliRule = class CopilotcliRule extends CopilotRule {
60787
61949
  }
60788
61950
  };
60789
61951
  //#endregion
61952
+ //#region src/features/rules/crush-rule.ts
61953
+ var CrushRule = class CrushRule extends ToolRule {
61954
+ constructor({ fileContent, root, ...rest }) {
61955
+ super({
61956
+ ...rest,
61957
+ fileContent,
61958
+ root: root ?? false
61959
+ });
61960
+ }
61961
+ static getSettablePaths({ global = false } = {}) {
61962
+ if (global) return { root: {
61963
+ relativeDirPath: CRUSH_GLOBAL_DIR,
61964
+ relativeFilePath: CRUSH_RULE_FILE_NAME
61965
+ } };
61966
+ return { root: {
61967
+ relativeDirPath: ".",
61968
+ relativeFilePath: CRUSH_RULE_FILE_NAME
61969
+ } };
61970
+ }
61971
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
61972
+ const { root } = this.getSettablePaths({ global });
61973
+ const relativePath = (0, node_path.join)(root.relativeDirPath, root.relativeFilePath);
61974
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
61975
+ return new CrushRule({
61976
+ outputRoot,
61977
+ relativeDirPath: root.relativeDirPath,
61978
+ relativeFilePath: root.relativeFilePath,
61979
+ fileContent,
61980
+ validate,
61981
+ root: true
61982
+ });
61983
+ }
61984
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
61985
+ const { root } = this.getSettablePaths({ global });
61986
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
61987
+ return new CrushRule({
61988
+ outputRoot,
61989
+ relativeDirPath: root.relativeDirPath,
61990
+ relativeFilePath: root.relativeFilePath,
61991
+ fileContent: rulesyncRule.getBody(),
61992
+ validate,
61993
+ root: isRoot
61994
+ });
61995
+ }
61996
+ toRulesyncRule() {
61997
+ return this.toRulesyncRuleDefault();
61998
+ }
61999
+ validate() {
62000
+ return {
62001
+ success: true,
62002
+ error: null
62003
+ };
62004
+ }
62005
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
62006
+ const { root } = this.getSettablePaths({ global });
62007
+ const isRoot = relativeFilePath === root.relativeFilePath && relativeDirPath === root.relativeDirPath;
62008
+ return new CrushRule({
62009
+ outputRoot,
62010
+ relativeDirPath,
62011
+ relativeFilePath,
62012
+ fileContent: "",
62013
+ validate: false,
62014
+ root: isRoot
62015
+ });
62016
+ }
62017
+ static isTargetedByRulesyncRule(rulesyncRule) {
62018
+ return this.isTargetedByRulesyncRuleDefault({
62019
+ rulesyncRule,
62020
+ toolTarget: "crush"
62021
+ });
62022
+ }
62023
+ };
62024
+ //#endregion
60790
62025
  //#region src/features/rules/cursor-rule.ts
60791
62026
  const CursorRuleFrontmatterSchema = zod_mini.z.object({
60792
62027
  description: zod_mini.z.optional(zod_mini.z.string()),
@@ -61394,13 +62629,34 @@ var DevinRule = class DevinRule extends ToolRule {
61394
62629
  };
61395
62630
  //#endregion
61396
62631
  //#region src/features/rules/factorydroid-rule.ts
62632
+ /**
62633
+ * Rule generator for Factory Droid.
62634
+ *
62635
+ * Factory Droid loads the root `AGENTS.md` (project) / `~/.factory/AGENTS.md`
62636
+ * (global) as coding guidelines, plus non-root rules referenced from it via
62637
+ * `.factory/rules/*.md`.
62638
+ *
62639
+ * Factory Droid also loads `DESIGN.md` (project only) as a second,
62640
+ * independent instruction surface: "Always-on design-system, UX, visual, and
62641
+ * interaction guidance", loaded separately from `AGENTS.md`'s coding
62642
+ * guidelines. Rulesync emits it from any non-root rule that opts in via a
62643
+ * `factorydroid.channel: design` frontmatter block — those rule bodies are
62644
+ * routed to `DESIGN.md` instead of `AGENTS.md`/`.factory/rules/*.md`, and
62645
+ * multiple opted-in rules concatenate in source order. Factory's docs describe
62646
+ * `DESIGN.md` at the repository root and in nested subdirectories, like
62647
+ * `AGENTS.md`, but document no personal/global home-directory equivalent, so
62648
+ * this channel is project scope only.
62649
+ * @see https://docs.factory.ai/cli/configuration/agents-md
62650
+ */
61397
62651
  var FactorydroidRule = class FactorydroidRule extends ToolRule {
61398
- constructor({ fileContent, root, ...rest }) {
62652
+ design;
62653
+ constructor({ fileContent, root, design = false, ...rest }) {
61399
62654
  super({
61400
62655
  ...rest,
61401
62656
  fileContent,
61402
62657
  root: root ?? false
61403
62658
  });
62659
+ this.design = design;
61404
62660
  }
61405
62661
  static getSettablePaths({ global, excludeToolDir } = {}) {
61406
62662
  if (global) return { root: {
@@ -61412,11 +62668,47 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61412
62668
  relativeDirPath: ".",
61413
62669
  relativeFilePath: FACTORYDROID_RULE_FILE_NAME
61414
62670
  },
61415
- nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) }
62671
+ nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) },
62672
+ design: {
62673
+ relativeDirPath: ".",
62674
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME
62675
+ }
61416
62676
  };
61417
62677
  }
61418
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
62678
+ /**
62679
+ * Extra fixed files this tool manages beyond the root/non-root rules. The
62680
+ * RulesProcessor enumerates these for import and deletion so a stale
62681
+ * `DESIGN.md` is cleaned up once no rule opts in anymore. Empty in global
62682
+ * mode: `DESIGN.md` has no documented home-directory equivalent.
62683
+ */
62684
+ static getExtraFixedFiles({ global = false } = {}) {
62685
+ if (global) return [];
62686
+ return [this.getSettablePaths({ global }).design];
62687
+ }
62688
+ /**
62689
+ * Factory Droid loads `DESIGN.md` itself, so listing it in the root rule's
62690
+ * TOON reference section would double-load the content (and misrepresent it
62691
+ * as a rule the model must remember to open).
62692
+ */
62693
+ isExcludedFromRootReferences() {
62694
+ return this.design;
62695
+ }
62696
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
61419
62697
  const paths = this.getSettablePaths({ global });
62698
+ const design = !global ? paths.design : void 0;
62699
+ if (design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath) {
62700
+ const relativePath = (0, node_path.join)(design.relativeDirPath, design.relativeFilePath);
62701
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
62702
+ return new FactorydroidRule({
62703
+ outputRoot,
62704
+ relativeDirPath: design.relativeDirPath,
62705
+ relativeFilePath: design.relativeFilePath,
62706
+ fileContent,
62707
+ validate,
62708
+ root: false,
62709
+ design: true
62710
+ });
62711
+ }
61420
62712
  if (relativeFilePath === paths.root.relativeFilePath) {
61421
62713
  const relativePath = (0, node_path.join)(paths.root.relativeDirPath, paths.root.relativeFilePath);
61422
62714
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
@@ -61443,18 +62735,34 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61443
62735
  }
61444
62736
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61445
62737
  const paths = this.getSettablePaths({ global });
61446
- const isRoot = relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
62738
+ const design = !global ? paths.design : void 0;
62739
+ const isDesign = design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath;
62740
+ const isRoot = !isDesign && relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
61447
62741
  return new FactorydroidRule({
61448
62742
  outputRoot,
61449
62743
  relativeDirPath,
61450
62744
  relativeFilePath,
61451
62745
  fileContent: "",
61452
62746
  validate: false,
61453
- root: isRoot
62747
+ root: isRoot,
62748
+ design: isDesign
61454
62749
  });
61455
62750
  }
61456
62751
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
62752
+ const frontmatter = rulesyncRule.getFrontmatter();
61457
62753
  const paths = this.getSettablePaths({ global });
62754
+ if (!global && !frontmatter.root && frontmatter.factorydroid?.channel === "design") {
62755
+ const { design } = paths;
62756
+ return new FactorydroidRule({
62757
+ outputRoot,
62758
+ relativeDirPath: design.relativeDirPath,
62759
+ relativeFilePath: design.relativeFilePath,
62760
+ fileContent: rulesyncRule.getBody(),
62761
+ validate,
62762
+ root: false,
62763
+ design: true
62764
+ });
62765
+ }
61458
62766
  return new FactorydroidRule(this.buildToolRuleParamsAgentsmd({
61459
62767
  outputRoot,
61460
62768
  rulesyncRule,
@@ -61464,6 +62772,17 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61464
62772
  }));
61465
62773
  }
61466
62774
  toRulesyncRule() {
62775
+ if (this.design) return new RulesyncRule({
62776
+ outputRoot: process.cwd(),
62777
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
62778
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME,
62779
+ frontmatter: {
62780
+ root: false,
62781
+ targets: ["factorydroid"],
62782
+ factorydroid: { channel: "design" }
62783
+ },
62784
+ body: this.getFileContent()
62785
+ });
61467
62786
  return this.toRulesyncRuleDefault();
61468
62787
  }
61469
62788
  validate() {
@@ -63981,6 +65300,16 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
63981
65300
  ruleDiscoveryMode: "auto"
63982
65301
  }
63983
65302
  }],
65303
+ ["codebuddy", {
65304
+ class: CodebuddyRule,
65305
+ meta: {
65306
+ extension: "md",
65307
+ supportsGlobal: true,
65308
+ ruleDiscoveryMode: "auto",
65309
+ localRootMode: "separate-local-file",
65310
+ localRootFileName: CODEBUDDY_LOCAL_RULE_FILE_NAME
65311
+ }
65312
+ }],
63984
65313
  ["codexcli", {
63985
65314
  class: CodexcliRule,
63986
65315
  meta: {
@@ -64006,6 +65335,15 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
64006
65335
  ruleDiscoveryMode: "auto"
64007
65336
  }
64008
65337
  }],
65338
+ ["crush", {
65339
+ class: CrushRule,
65340
+ meta: {
65341
+ extension: "md",
65342
+ supportsGlobal: true,
65343
+ ruleDiscoveryMode: "auto",
65344
+ collisionPolicy: "fold"
65345
+ }
65346
+ }],
64009
65347
  ["cursor", {
64010
65348
  class: CursorRule,
64011
65349
  meta: {
@@ -64374,6 +65712,10 @@ var RulesProcessor = class extends FeatureProcessor {
64374
65712
  outputFiles,
64375
65713
  convertedRules
64376
65714
  });
65715
+ await this.warnForDeactivatedImportOnlyRoots({
65716
+ toolRules,
65717
+ factory
65718
+ });
64377
65719
  return outputFiles;
64378
65720
  }
64379
65721
  /**
@@ -64595,6 +65937,41 @@ var RulesProcessor = class extends FeatureProcessor {
64595
65937
  }
64596
65938
  }
64597
65939
  /**
65940
+ * Warn when this generate run is about to write a root rule file that will
65941
+ * make the tool stop reading paths it currently reads instead — Junie's
65942
+ * `.junie/rules/*.md` and `.junie/playbook.md` become unreachable the
65943
+ * moment `.junie/AGENTS.md` exists, since Junie reads the root file
65944
+ * exclusively once it is present. `importOnlyRoots` with
65945
+ * `onlyWhenRootAbsent` already models exactly this shape for import; this
65946
+ * reuses the same declaration so the
65947
+ * `generate` path — which never calls `loadToolFiles` and so never reached
65948
+ * the existing import-side warning — surfaces it too. Without this, a repo
65949
+ * that only ever runs `generate` never sees any warning: the deactivated
65950
+ * files stay on disk, untouched and not gitignored, silently unread.
65951
+ */
65952
+ async warnForDeactivatedImportOnlyRoots({ toolRules, factory }) {
65953
+ const rootRule = toolRules.find((rule) => rule.isRoot());
65954
+ if (!rootRule) return;
65955
+ const settablePaths = factory.class.getSettablePaths({ global: this.global });
65956
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
65957
+ if (!importOnlyRoots || importOnlyRoots.length === 0) return;
65958
+ const existingPaths = [];
65959
+ for (const importOnlyRoot of importOnlyRoots) {
65960
+ if (importOnlyRoot.onlyWhenRootAbsent !== true) continue;
65961
+ const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
65962
+ cwd: this.outputRoot,
65963
+ type: "file"
65964
+ });
65965
+ existingPaths.push(...matchedPaths);
65966
+ }
65967
+ if (existingPaths.length === 0) return;
65968
+ const rootFileRelativePath = (0, node_path.join)(rootRule.getRelativeDirPath(), rootRule.getRelativeFilePath());
65969
+ const names = existingPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
65970
+ const listedNames = names.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
65971
+ const remainingCount = names.length - listedNames.length;
65972
+ this.logger.warn(`Writing ${stripControlCharacters(rootFileRelativePath)} for ${this.toolTarget} means ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} will no longer be read. Run \`rulesync import --targets ${this.toolTarget}\` first to carry that content into ${RULESYNC_RULES_RELATIVE_DIR_PATH}, or delete ${listedNames.length === 1 ? "it" : "them"} once you have checked the content is already in the root file.`);
65973
+ }
65974
+ /**
64598
65975
  * Handle localRoot rule generation based on tool target.
64599
65976
  * - `separate-local-file`: writes a dedicated `*.local.md` root file
64600
65977
  * (claudecode/legacy: `./CLAUDE.local.md`, rovodev: `./AGENTS.local.md`)
@@ -64846,6 +66223,26 @@ As this project's AI coding tool, you must follow the additional conventions bel
64846
66223
  }));
64847
66224
  }
64848
66225
  /**
66226
+ * Load and merge rulesync rule files from every configured input root's
66227
+ * `.rulesync/rules/` directory, by relative path, so that a rule with the
66228
+ * same target path from a later root replaces the earlier root's copy
66229
+ * (case-insensitive, matching the intra-root collision handling).
66230
+ *
66231
+ * This is the side-effect-free half of `loadRulesyncFiles`: it does not
66232
+ * warn about a missing root rule or validate `localRoot` placement, so it
66233
+ * is also safe to call from code paths — like
66234
+ * `warnForFoldImportDuplicationRisk` — that only need the merged rule set
66235
+ * and must not trigger `loadRulesyncFiles`'s generate-time checks.
66236
+ */
66237
+ async loadMergedRulesyncRules() {
66238
+ return mergeByCaseInsensitiveIdentity({
66239
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
66240
+ identity: (rule) => rule.getRelativeFilePath(),
66241
+ artifactName: "rule",
66242
+ logger: this.logger
66243
+ });
66244
+ }
66245
+ /**
64849
66246
  * Implementation of abstract method from FeatureProcessor
64850
66247
  * Load and parse rulesync rule files from every configured input root's
64851
66248
  * `.rulesync/rules/` directory, merging by relative path so that a rule
@@ -64853,12 +66250,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
64853
66250
  * copy (case-insensitive, matching the intra-root collision handling).
64854
66251
  */
64855
66252
  async loadRulesyncFiles() {
64856
- const rulesyncRules = mergeByCaseInsensitiveIdentity({
64857
- perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
64858
- identity: (rule) => rule.getRelativeFilePath(),
64859
- artifactName: "rule",
64860
- logger: this.logger
64861
- });
66253
+ const rulesyncRules = await this.loadMergedRulesyncRules();
64862
66254
  const factory = this.getFactory(this.toolTarget);
64863
66255
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
64864
66256
  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}.`);
@@ -64915,6 +66307,46 @@ As this project's AI coding tool, you must follow the additional conventions bel
64915
66307
  });
64916
66308
  }
64917
66309
  /**
66310
+ * Warn when importing a `collisionPolicy: "fold"` target's root file while
66311
+ * `.rulesync/rules/` still holds non-root rules targeting it. A fold target
66312
+ * (codexcli, junie, and others) concatenates every targeted non-root rule
66313
+ * into its one root output file on `generate`. Importing that root file
66314
+ * back therefore re-reads the already-folded content as a single new
66315
+ * rulesync rule, while the original non-root rules stay in place
66316
+ * untouched — the next `generate` folds both together, duplicating the
66317
+ * content once per generate/import cycle with nothing to indicate why.
66318
+ *
66319
+ * This does not attempt to detect or drop the specific duplicated content
66320
+ * (the root file has no marker recording which rule contributed what); it
66321
+ * only surfaces that the cycle produces one, per the "at minimum, warn"
66322
+ * option recorded on issue #2743.
66323
+ *
66324
+ * Only the actual `rulesync import` call site invokes this (and only once
66325
+ * it has confirmed there is something to import) — `loadToolFiles` is also
66326
+ * the entry point for `rulesync convert` and `rulesync fetch`, neither of
66327
+ * which writes to `.rulesync/rules/` or carries this duplication risk.
66328
+ *
66329
+ * Reads via `loadMergedRulesyncRules` rather than `loadRulesyncFiles`
66330
+ * deliberately: this runs before the imported root file is written, so
66331
+ * `.rulesync/rules/` never yet has a root rule targeting this tool, and
66332
+ * `loadRulesyncFiles`'s "no root rule found" warning and `localRoot`
66333
+ * validation (which can throw) would fire spuriously on every fold-tool
66334
+ * import — including ones where nothing is actually misconfigured.
66335
+ *
66336
+ * In global mode, a `localRoot: true` rule is excluded from the
66337
+ * duplication check the same way `loadRulesyncFiles`'s global-mode branch
66338
+ * excludes it from `nonRootRules`: `generate` ignores `localRoot` entirely
66339
+ * in global mode, so such a rule is never actually folded into the global
66340
+ * root output and warning about it here would be inaccurate.
66341
+ */
66342
+ async warnForFoldImportDuplicationRisk() {
66343
+ const factory = this.getFactory(this.toolTarget);
66344
+ if (factory.meta.collisionPolicy !== "fold") return;
66345
+ const nonRootRules = (await this.loadMergedRulesyncRules()).filter((rule) => !rule.getFrontmatter().root && (!this.global || !rule.getFrontmatter().localRoot) && factory.class.isTargetedByRulesyncRule(rule));
66346
+ if (nonRootRules.length === 0) return;
66347
+ this.logger.warn(`Importing ${this.toolTarget}'s root file will re-add content already folded from ${formatRulePaths(nonRootRules)}: ${this.toolTarget} concatenates every non-root rule into its single root output file, so the imported copy duplicates them the next time you run \`rulesync generate --targets ${this.toolTarget}\`. Review the imported rule and remove the duplicated content, or remove the original non-root rule files, before generating again.`);
66348
+ }
66349
+ /**
64918
66350
  * Implementation of abstract method from FeatureProcessor
64919
66351
  * Load tool-specific rule configurations and parse them into ToolRule instances
64920
66352
  */
@@ -66978,6 +68410,7 @@ async function importRulesCore(params) {
66978
68410
  logger.warn(`No rule files found for ${tool}. Skipping import.`);
66979
68411
  return 0;
66980
68412
  }
68413
+ await rulesProcessor.warnForFoldImportDuplicationRisk();
66981
68414
  const rulesyncFiles = await rulesProcessor.convertToolFilesToRulesyncFiles(toolFiles);
66982
68415
  const { count: writtenCount } = await rulesProcessor.writeAiFiles(rulesyncFiles);
66983
68416
  if (config.getVerbose() && writtenCount > 0) logger.success(`Created ${writtenCount} rule files`);
@@ -67297,6 +68730,18 @@ Object.defineProperty(exports, "CLIError", {
67297
68730
  return CLIError;
67298
68731
  }
67299
68732
  });
68733
+ Object.defineProperty(exports, "CODEBUDDY_DIR", {
68734
+ enumerable: true,
68735
+ get: function() {
68736
+ return CODEBUDDY_DIR;
68737
+ }
68738
+ });
68739
+ Object.defineProperty(exports, "CODEBUDDY_LOCAL_RULE_FILE_NAME", {
68740
+ enumerable: true,
68741
+ get: function() {
68742
+ return CODEBUDDY_LOCAL_RULE_FILE_NAME;
68743
+ }
68744
+ });
67300
68745
  Object.defineProperty(exports, "CODEXCLI_BASH_RULES_FILE_NAME", {
67301
68746
  enumerable: true,
67302
68747
  get: function() {
@@ -67879,6 +69324,12 @@ Object.defineProperty(exports, "hasDeceptiveHiddenCharacters", {
67879
69324
  return hasDeceptiveHiddenCharacters;
67880
69325
  }
67881
69326
  });
69327
+ Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
69328
+ enumerable: true,
69329
+ get: function() {
69330
+ return hasEnclosingMarkOutsideKeycap;
69331
+ }
69332
+ });
67882
69333
  Object.defineProperty(exports, "importFromTool", {
67883
69334
  enumerable: true,
67884
69335
  get: function() {