rulesync 16.21.0 → 16.22.1

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.
@@ -221,6 +221,35 @@ function hasDeceptiveHiddenCharacters(text) {
221
221
  return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
222
222
  });
223
223
  }
224
+ /** A mark that draws around the character before it — a circle, a square, a keycap box. */
225
+ const ENCLOSING_MARK_PATTERN = /\p{Me}/u;
226
+ /**
227
+ * Whether `text` carries an enclosing mark that is not the keycap of an emoji
228
+ * keycap sequence.
229
+ *
230
+ * An enclosing mark (`\p{Me}`: U+20DD COMBINING ENCLOSING CIRCLE, U+20E3
231
+ * COMBINING ENCLOSING KEYCAP, the Cyrillic and Vedic ones) is drawn over the
232
+ * character before it and takes no column of its own, so `pdf` with one after
233
+ * it occupies the three columns of `pdf` and is a fourth directory underneath.
234
+ * Unlike a joiner or a variation selector it is not invisible — the box is
235
+ * drawn — which is why `hasDeceptiveHiddenCharacters` does not refuse it and
236
+ * why it is a question for the confusable-name note instead: the row is drawn,
237
+ * only not the way its name reads. The one place an enclosing mark belongs in
238
+ * a name is the keycap sequence of UTS #51, which `isKeycapSequence` matches
239
+ * whole; every other one is left over.
240
+ *
241
+ * Restricted to `\p{Me}` on purpose: a non-spacing mark (`\p{Mn}`) is how
242
+ * Devanagari, Arabic and Vietnamese write, and folding those would mark
243
+ * ordinary names in every one of them.
244
+ */
245
+ function hasEnclosingMarkOutsideKeycap(text) {
246
+ const characters = [...text];
247
+ return characters.some((character, index) => ENCLOSING_MARK_PATTERN.test(character) && !isKeycapSequence({
248
+ base: characters[index - 2],
249
+ selector: characters[index - 1] ?? "",
250
+ following: character
251
+ }));
252
+ }
224
253
  //#endregion
225
254
  //#region src/utils/truncate.ts
226
255
  /**
@@ -444,9 +473,11 @@ const rulesProcessorToolTargetTuple = [
444
473
  "claudecode",
445
474
  "claudecode-legacy",
446
475
  "cline",
476
+ "codebuddy",
447
477
  "codexcli",
448
478
  "copilot",
449
479
  "copilotcli",
480
+ "crush",
450
481
  "cursor",
451
482
  "deepagents",
452
483
  "factorydroid",
@@ -482,6 +513,7 @@ const ignoreProcessorToolTargetTuple = [
482
513
  "claudecode",
483
514
  "claudecode-legacy",
484
515
  "cline",
516
+ "crush",
485
517
  "cursor",
486
518
  "hermesagent",
487
519
  "junie",
@@ -623,6 +655,7 @@ const skillsProcessorToolTargetTuple = [
623
655
  "codexcli",
624
656
  "copilot",
625
657
  "copilotcli",
658
+ "crush",
626
659
  "cursor",
627
660
  "deepagents",
628
661
  "factorydroid",
@@ -3327,6 +3360,83 @@ var RulesyncFile = class extends AiFile {
3327
3360
  }
3328
3361
  };
3329
3362
  //#endregion
3363
+ //#region src/utils/bounded-walk.ts
3364
+ const ALIAS_HINT = "(a chain of YAML aliases may be amplifying the document)";
3365
+ /**
3366
+ * Create the bookkeeping for one walk. `subject` names the document kind in
3367
+ * every error ("Frontmatter", "Shared config"); `root`, when given, is entered
3368
+ * up front so the root object counts as the first nesting level, matching the
3369
+ * +1 that `enter` applies to every container nested inside it.
3370
+ */
3371
+ function createBoundedWalk({ subject, limits, root }) {
3372
+ const ancestors = /* @__PURE__ */ new WeakSet();
3373
+ let valuesRemaining = limits.maxValues;
3374
+ let stringCharsRemaining = limits.maxStringChars;
3375
+ let depth = 0;
3376
+ const chargeChars = (chars) => {
3377
+ stringCharsRemaining -= chars;
3378
+ if (stringCharsRemaining < 0) throw new Error(`${subject}'s string values expand to more than ${limits.maxStringChars} characters; refusing to process it ${ALIAS_HINT}`);
3379
+ };
3380
+ const chargeValue = (stringChars = 0) => {
3381
+ valuesRemaining -= 1;
3382
+ if (valuesRemaining < 0) throw new Error(`${subject} expands to more than ${limits.maxValues} values; refusing to process it ${ALIAS_HINT}`);
3383
+ chargeChars(stringChars);
3384
+ };
3385
+ const enter = (container) => {
3386
+ depth += 1;
3387
+ if (depth > limits.maxDepth) throw new Error(`${subject} nests more than ${limits.maxDepth} levels deep; refusing to process it ${ALIAS_HINT}`);
3388
+ ancestors.add(container);
3389
+ };
3390
+ const leave = (container) => {
3391
+ ancestors.delete(container);
3392
+ depth -= 1;
3393
+ };
3394
+ if (root !== void 0) enter(root);
3395
+ return {
3396
+ chargeValue,
3397
+ chargeChars,
3398
+ isAncestor: (container) => ancestors.has(container),
3399
+ enter,
3400
+ leave
3401
+ };
3402
+ }
3403
+ //#endregion
3404
+ //#region src/utils/prototype-pollution.ts
3405
+ /**
3406
+ * Keys that, if walked into when constructing or merging objects from
3407
+ * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
3408
+ * chain) and propagate state to every other object in the runtime. Any code
3409
+ * that copies arbitrary user-supplied keys into a fresh object — frontmatter
3410
+ * parsing, MCP config conversion, settings round-trip — should skip these.
3411
+ */
3412
+ const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
3413
+ "__proto__",
3414
+ "constructor",
3415
+ "prototype"
3416
+ ]);
3417
+ function isPrototypePollutionKey(key) {
3418
+ return PROTOTYPE_POLLUTION_KEYS.has(key);
3419
+ }
3420
+ /**
3421
+ * Returns a shallow copy of a record's own entries with every
3422
+ * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
3423
+ *
3424
+ * Use when copying a nested, user-supplied string map — an MCP server's `env`
3425
+ * or `headers` table — into freshly generated config. Carrying such a map by
3426
+ * reference, or re-assigning its keys via bracket notation, would let a literal
3427
+ * `__proto__` key ride along (and re-assigning it would mutate the target's
3428
+ * prototype). Walking the entries through this helper severs that path while
3429
+ * preserving every legitimate key.
3430
+ */
3431
+ function omitPrototypePollutionKeys(record) {
3432
+ const sanitized = {};
3433
+ for (const [key, value] of Object.entries(record)) {
3434
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3435
+ sanitized[key] = value;
3436
+ }
3437
+ return sanitized;
3438
+ }
3439
+ //#endregion
3330
3440
  //#region src/utils/type-guards.ts
3331
3441
  /**
3332
3442
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -3391,67 +3501,154 @@ function loadYaml(content) {
3391
3501
  }
3392
3502
  //#endregion
3393
3503
  //#region src/utils/frontmatter.ts
3394
- function deepRemoveNullishValue(value) {
3504
+ /**
3505
+ * Upper bound on the number of values a frontmatter document may expand to
3506
+ * once every YAML alias is written out.
3507
+ *
3508
+ * A YAML alias makes one parsed container reachable from many keys, and the
3509
+ * cleaners below copy each reachable value, so a small file with a few levels
3510
+ * of nested aliases (an "alias bomb") can expand into megabytes of output or
3511
+ * exhaust the heap. Counting every visited value against this budget turns
3512
+ * that into an error instead. Real frontmatter is a handful of keys; even a
3513
+ * generous skill manifest stays orders of magnitude below the limit.
3514
+ */
3515
+ const MAX_FRONTMATTER_VALUES = 1e5;
3516
+ /**
3517
+ * Upper bound on the total character count of string leaves a frontmatter
3518
+ * document may expand to.
3519
+ *
3520
+ * {@link MAX_FRONTMATTER_VALUES} bounds how many values are visited, but a
3521
+ * single long string aliased thousands of times still fits that budget while
3522
+ * the duplicated output balloons: one scalar of a few KB, chained through a
3523
+ * handful of aliases within the value budget, can multiply into a document
3524
+ * many megabytes larger than it started. Charging every visited string's
3525
+ * length against this separate budget bounds that output regardless of how
3526
+ * many aliases point at it.
3527
+ */
3528
+ const MAX_FRONTMATTER_STRING_CHARS = 4e6;
3529
+ /**
3530
+ * Upper bound on the raw character length of the `---`-delimited frontmatter
3531
+ * block itself, checked before it is ever handed to the YAML parser.
3532
+ *
3533
+ * The budgets above only bound the *parsed* document — the walk over
3534
+ * `matter()`'s output — but a complex YAML key (an array or mapping used as a
3535
+ * mapping key) is joined into a string by js-yaml while it parses, and a
3536
+ * mapping with many such keys can cost real memory before that walk ever
3537
+ * starts, or even before `matter()` returns. Capping the raw block size keeps
3538
+ * that parse-time cost bounded regardless of what the block contains. Real
3539
+ * frontmatter blocks are a few hundred bytes at most; even a large project
3540
+ * manifest stays well under this.
3541
+ */
3542
+ const MAX_FRONTMATTER_RAW_CHARS = 65536;
3543
+ /**
3544
+ * Estimate the serialized character cost of a leaf that is not a string (a
3545
+ * string leaf is charged by its own length instead).
3546
+ *
3547
+ * js-yaml's default schema resolves `!!binary` scalars to a `Uint8Array`, and
3548
+ * its dumper writes one back out as base64 — roughly 4 output characters per
3549
+ * 3 input bytes. Without this, an aliased binary blob would walk the budget
3550
+ * for free even though it can dominate the emitted document's size.
3551
+ */
3552
+ function estimateLeafChars(value) {
3553
+ if (value instanceof Uint8Array) return Math.ceil(value.byteLength / 3) * 4;
3554
+ return 0;
3555
+ }
3556
+ /**
3557
+ * Copy one parsed value, dropping nullish leaves and cyclic references.
3558
+ *
3559
+ * Every alias is still written out as an independent copy, as gray-matter's
3560
+ * default YAML engine would otherwise serialize shared references as `&ref_0`
3561
+ * anchors that simplified frontmatter parsers cannot read; the expansion is
3562
+ * bounded by {@link MAX_FRONTMATTER_VALUES} instead.
3563
+ */
3564
+ function deepCleanValue(value, options) {
3565
+ const leafChars = typeof value === "string" ? value.length : estimateLeafChars(value);
3566
+ options.walk.chargeValue(leafChars);
3395
3567
  if (value === null || value === void 0) return;
3396
- if (Array.isArray(value)) return value.map((item) => deepRemoveNullishValue(item)).filter((item) => item !== void 0);
3568
+ if (typeof value === "string") return options.transformString ? options.transformString(value) : value;
3569
+ if (Array.isArray(value)) {
3570
+ if (options.walk.isAncestor(value)) return;
3571
+ options.walk.enter(value);
3572
+ const cleanedArray = [];
3573
+ for (const item of value) {
3574
+ const cleaned = deepCleanValue(item, options);
3575
+ if (cleaned !== void 0) cleanedArray.push(cleaned);
3576
+ }
3577
+ options.walk.leave(value);
3578
+ return cleanedArray;
3579
+ }
3397
3580
  if (isPlainObject$1(value)) {
3398
- const result = {};
3399
- for (const [key, val] of Object.entries(value)) {
3400
- const cleaned = deepRemoveNullishValue(val);
3401
- if (cleaned !== void 0) result[key] = cleaned;
3402
- }
3581
+ if (options.walk.isAncestor(value)) return;
3582
+ options.walk.enter(value);
3583
+ const result = cleanOwnEntries(value, options);
3584
+ options.walk.leave(value);
3403
3585
  return result;
3404
3586
  }
3405
3587
  return value;
3406
3588
  }
3407
- function deepRemoveNullishObject(obj) {
3408
- if (!obj || typeof obj !== "object") return {};
3589
+ /**
3590
+ * Copy the cleaned own entries of a parsed object into a fresh record.
3591
+ *
3592
+ * A YAML parser defines a `__proto__:` key as an own property, and assigning
3593
+ * it back with bracket notation would instead replace the new record's
3594
+ * prototype, whose members zod's loose object schemas then promote to real
3595
+ * keys. So a fetched skill could hide `allowed-tools` under an innocuous
3596
+ * looking `__proto__:` block. That key, `constructor` and `prototype` are
3597
+ * therefore dropped rather than copied, and cannot be used as frontmatter
3598
+ * keys.
3599
+ */
3600
+ function cleanOwnEntries(obj, options) {
3409
3601
  const result = {};
3410
3602
  for (const [key, val] of Object.entries(obj)) {
3411
- const cleaned = deepRemoveNullishValue(val);
3603
+ options.walk.chargeChars(key.length);
3604
+ const cleaned = deepCleanValue(val, options);
3605
+ if (isPrototypePollutionKey(key)) continue;
3412
3606
  if (cleaned !== void 0) result[key] = cleaned;
3413
3607
  }
3414
3608
  return result;
3415
3609
  }
3416
- function deepFlattenStringsValue(value) {
3417
- if (value === null || value === void 0) return;
3418
- if (typeof value === "string") return value.replace(/\n+/g, " ").trim();
3419
- if (Array.isArray(value)) return value.map((item) => deepFlattenStringsValue(item)).filter((item) => item !== void 0);
3420
- if (isPlainObject$1(value)) {
3421
- const result = {};
3422
- for (const [key, val] of Object.entries(value)) {
3423
- const cleaned = deepFlattenStringsValue(val);
3424
- if (cleaned !== void 0) result[key] = cleaned;
3425
- }
3426
- return result;
3427
- }
3428
- return value;
3610
+ function deepCleanObject(obj, options) {
3611
+ if (!obj || typeof obj !== "object") return {};
3612
+ return cleanOwnEntries(obj, {
3613
+ ...options,
3614
+ walk: createBoundedWalk({
3615
+ subject: "Frontmatter",
3616
+ limits: {
3617
+ maxValues: MAX_FRONTMATTER_VALUES,
3618
+ maxStringChars: MAX_FRONTMATTER_STRING_CHARS,
3619
+ maxDepth: 64
3620
+ },
3621
+ root: obj
3622
+ })
3623
+ });
3624
+ }
3625
+ /** Drop null and undefined values, recursively. */
3626
+ function deepRemoveNullishObject(obj) {
3627
+ return deepCleanObject(obj, {});
3429
3628
  }
3629
+ /** Drop nullish values and collapse every string onto a single line. */
3430
3630
  function deepFlattenStringsObject(obj) {
3431
- if (!obj || typeof obj !== "object") return {};
3432
- const result = {};
3433
- for (const [key, val] of Object.entries(obj)) {
3434
- const cleaned = deepFlattenStringsValue(val);
3435
- if (cleaned !== void 0) result[key] = cleaned;
3436
- }
3437
- return result;
3631
+ return deepCleanObject(obj, { transformString: (value) => value.replace(/\n+/g, " ").trim() });
3438
3632
  }
3439
3633
  function stringifyFrontmatter(body, frontmatter, options) {
3440
3634
  const { avoidBlockScalars = false } = options ?? {};
3441
3635
  const cleanFrontmatter = avoidBlockScalars ? deepFlattenStringsObject(frontmatter) : deepRemoveNullishObject(frontmatter);
3442
- if (avoidBlockScalars) return matter.stringify(body, cleanFrontmatter, { engines: { yaml: {
3636
+ const file = { content: body };
3637
+ if (avoidBlockScalars) return matter.stringify(file, cleanFrontmatter, { engines: { yaml: {
3443
3638
  parse: (input) => loadYaml(input) ?? {},
3444
3639
  stringify: (data) => dump(data, { lineWidth: -1 })
3445
3640
  } } });
3446
- return matter.stringify(body, cleanFrontmatter);
3641
+ return matter.stringify(file, cleanFrontmatter);
3447
3642
  }
3448
3643
  function parseFrontmatter(content, filePath) {
3449
3644
  let frontmatter;
3450
3645
  let body;
3451
3646
  let hasFrontmatter;
3452
3647
  try {
3648
+ const bounds = findFrontmatterBlockBounds(content);
3649
+ 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)`);
3453
3650
  const result = matter(content, {});
3454
- frontmatter = result.data;
3651
+ frontmatter = deepRemoveNullishObject(result.data);
3455
3652
  body = result.content;
3456
3653
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
3457
3654
  } catch (error) {
@@ -3459,7 +3656,7 @@ function parseFrontmatter(content, filePath) {
3459
3656
  throw error;
3460
3657
  }
3461
3658
  return {
3462
- frontmatter: deepRemoveNullishObject(frontmatter),
3659
+ frontmatter,
3463
3660
  body,
3464
3661
  hasFrontmatter
3465
3662
  };
@@ -3508,17 +3705,34 @@ function repairFrontmatterLine(line) {
3508
3705
  };
3509
3706
  }
3510
3707
  /**
3511
- * Quote the unquoted scalars that make a frontmatter block unparseable, or
3512
- * return `undefined` when there is nothing to repair. Only the frontmatter
3513
- * block is rewritten; the body is passed through untouched.
3708
+ * Locate a raw `---`-delimited frontmatter block's bounds within `content`,
3709
+ * without parsing it. Shared by the size guard in {@link parseFrontmatter} and
3710
+ * the YAML repair pass below, so both agree on exactly what gray-matter would
3711
+ * treat as the block: gray-matter ends it at the first `\n---`, with no
3712
+ * requirement that the delimiter be alone on its line, so a stricter pattern
3713
+ * here would run past gray-matter's delimiter and act on text that is really
3714
+ * the body.
3514
3715
  */
3515
- function repairMalformedFrontmatterYaml(content) {
3716
+ function findFrontmatterBlockBounds(content) {
3516
3717
  const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
3517
3718
  if (!opening) return;
3518
3719
  const blockStart = opening[0].length;
3519
3720
  const closing = /\r?\n---/.exec(content.slice(blockStart));
3520
3721
  if (!closing) return;
3521
- const blockEnd = blockStart + closing.index;
3722
+ return {
3723
+ blockStart,
3724
+ blockEnd: blockStart + closing.index
3725
+ };
3726
+ }
3727
+ /**
3728
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
3729
+ * return `undefined` when there is nothing to repair. Only the frontmatter
3730
+ * block is rewritten; the body is passed through untouched.
3731
+ */
3732
+ function repairMalformedFrontmatterYaml(content) {
3733
+ const bounds = findFrontmatterBlockBounds(content);
3734
+ if (!bounds) return;
3735
+ const { blockStart, blockEnd } = bounds;
3522
3736
  const block = content.slice(blockStart, blockEnd);
3523
3737
  const repairedLines = block.split("\n").map(repairFrontmatterLine);
3524
3738
  const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
@@ -5197,42 +5411,6 @@ const CANONICAL_TO_GROKCLI_EVENT_NAMES = {
5197
5411
  */
5198
5412
  const GROKCLI_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_GROKCLI_EVENT_NAMES).map(([k, v]) => [v, k]));
5199
5413
  //#endregion
5200
- //#region src/utils/prototype-pollution.ts
5201
- /**
5202
- * Keys that, if walked into when constructing or merging objects from
5203
- * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
5204
- * chain) and propagate state to every other object in the runtime. Any code
5205
- * that copies arbitrary user-supplied keys into a fresh object — frontmatter
5206
- * parsing, MCP config conversion, settings round-trip — should skip these.
5207
- */
5208
- const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
5209
- "__proto__",
5210
- "constructor",
5211
- "prototype"
5212
- ]);
5213
- function isPrototypePollutionKey(key) {
5214
- return PROTOTYPE_POLLUTION_KEYS.has(key);
5215
- }
5216
- /**
5217
- * Returns a shallow copy of a record's own entries with every
5218
- * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
5219
- *
5220
- * Use when copying a nested, user-supplied string map — an MCP server's `env`
5221
- * or `headers` table — into freshly generated config. Carrying such a map by
5222
- * reference, or re-assigning its keys via bracket notation, would let a literal
5223
- * `__proto__` key ride along (and re-assigning it would mutate the target's
5224
- * prototype). Walking the entries through this helper severs that path while
5225
- * preserving every legitimate key.
5226
- */
5227
- function omitPrototypePollutionKeys(record) {
5228
- const sanitized = {};
5229
- for (const [key, value] of Object.entries(record)) {
5230
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
5231
- sanitized[key] = value;
5232
- }
5233
- return sanitized;
5234
- }
5235
- //#endregion
5236
5414
  //#region src/utils/jsonc.ts
5237
5415
  /**
5238
5416
  * Rebuild the parsed value from its own enumerable entries, dropping
@@ -5983,6 +6161,17 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5983
6161
  const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
5984
6162
  throw new RulesyncSourceNotFoundError(`No ${join(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath)} found.`);
5985
6163
  }
6164
+ /**
6165
+ * Return one server exactly as authored, before `getMcpServers()` strips
6166
+ * rulesync- and tool-specific fields. Keep this lookup here so every target
6167
+ * that re-merges one of those fields shares the same own-property and
6168
+ * prototype-pollution guards.
6169
+ */
6170
+ getRawMcpServer(name) {
6171
+ if (isPrototypePollutionKey(name)) return void 0;
6172
+ const mcpServers = isRecord$1(this.json) ? this.json.mcpServers : void 0;
6173
+ return isRecord$1(mcpServers) && Object.hasOwn(mcpServers, name) ? mcpServers[name] : void 0;
6174
+ }
5986
6175
  getMcpServers() {
5987
6176
  const mcpServers = this.json.mcpServers ?? {};
5988
6177
  const entries = Object.entries(mcpServers);
@@ -7609,6 +7798,11 @@ const RulesyncRuleFrontmatterSchema = z.object({
7609
7798
  globs: z.optional(z.array(z.string())),
7610
7799
  agentsmd: z.optional(z.looseObject({ subprojectPath: z.optional(z.string()) })),
7611
7800
  claudecode: z.optional(z.looseObject({ paths: z.optional(z.array(z.string())) })),
7801
+ codebuddy: z.optional(z.looseObject({
7802
+ paths: z.optional(z.array(z.string())),
7803
+ alwaysApply: z.optional(z.boolean()),
7804
+ description: z.optional(z.string())
7805
+ })),
7612
7806
  cursor: z.optional(z.looseObject({
7613
7807
  alwaysApply: z.optional(z.boolean()),
7614
7808
  description: z.optional(z.string()),
@@ -7650,7 +7844,8 @@ const RulesyncRuleFrontmatterSchema = z.object({
7650
7844
  name: z.optional(z.string()),
7651
7845
  extends: z.optional(z.string()),
7652
7846
  facet: z.optional(z.enum(["policies", "output-contracts"]))
7653
- }))
7847
+ })),
7848
+ factorydroid: z.optional(z.looseObject({ channel: z.optional(z.enum(["design"])) }))
7654
7849
  });
7655
7850
  /**
7656
7851
  * The `agentsmd.subprojectPath` every consumer should act on, resolved once so
@@ -8765,15 +8960,15 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
8765
8960
  })),
8766
8961
  opencode: z.optional(z.looseObject({
8767
8962
  "allowed-tools": z.optional(z.array(z.string())),
8768
- license: z.optional(z.string()),
8769
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
8770
- metadata: z.optional(z.looseObject({}))
8963
+ license: z.optional(z.unknown()),
8964
+ compatibility: z.optional(z.unknown()),
8965
+ metadata: z.optional(z.unknown())
8771
8966
  })),
8772
8967
  kilo: z.optional(z.looseObject({
8773
8968
  "allowed-tools": z.optional(z.array(z.string())),
8774
- license: z.optional(z.string()),
8775
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
8776
- metadata: z.optional(z.looseObject({}))
8969
+ license: z.optional(z.unknown()),
8970
+ compatibility: z.optional(z.unknown()),
8971
+ metadata: z.optional(z.unknown())
8777
8972
  })),
8778
8973
  kiro: z.optional(z.looseObject({
8779
8974
  license: z.optional(z.string()),
@@ -8782,9 +8977,9 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
8782
8977
  })),
8783
8978
  deepagents: z.optional(z.looseObject({
8784
8979
  "allowed-tools": z.optional(z.array(z.string())),
8785
- license: z.optional(z.string()),
8786
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
8787
- metadata: z.optional(z.looseObject({}))
8980
+ license: z.optional(z.unknown()),
8981
+ compatibility: z.optional(z.unknown()),
8982
+ metadata: z.optional(z.unknown())
8788
8983
  })),
8789
8984
  copilot: z.optional(z.looseObject({
8790
8985
  license: z.optional(z.string()),
@@ -8891,6 +9086,13 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
8891
9086
  takt: z.optional(z.looseObject({
8892
9087
  name: z.optional(z.string()),
8893
9088
  extends: z.optional(z.string())
9089
+ })),
9090
+ crush: z.optional(z.looseObject({
9091
+ "disable-model-invocation": z.optional(z.boolean()),
9092
+ "user-invocable": z.optional(z.boolean()),
9093
+ license: z.optional(z.string()),
9094
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
9095
+ metadata: z.optional(z.looseObject({}))
8894
9096
  }))
8895
9097
  });
8896
9098
  /**
@@ -9116,7 +9318,7 @@ async function getLocalSkillDirNames(sourceTree) {
9116
9318
  *
9117
9319
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
9118
9320
  * default that applies to every tool supporting the flag (claudecode, copilot,
9119
- * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9321
+ * copilotcli, crush, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9120
9322
  * default with a per-target value. A defined section value (including `false`)
9121
9323
  * always wins over the root default.
9122
9324
  *
@@ -9135,7 +9337,7 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
9135
9337
  *
9136
9338
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
9137
9339
  * that applies to every tool supporting the flag (claudecode, copilot,
9138
- * copilotcli, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9340
+ * copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9139
9341
  * per-target value. A defined section value (including `false`) always wins
9140
9342
  * over the root default.
9141
9343
  *
@@ -9600,8 +9802,8 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
9600
9802
  * This only deletes files that are no longer in the rulesync source, not files that will be overwritten.
9601
9803
  */
9602
9804
  async removeOrphanAiFiles(existingFiles, generatedFiles) {
9603
- const generatedPaths = new Set(generatedFiles.map((f) => f.getFilePath()));
9604
- const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(f.getFilePath()));
9805
+ const generatedPaths = new Set(generatedFiles.map((f) => caseFoldIdentity(f.getFilePath())));
9806
+ const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())));
9605
9807
  for (const aiFile of orphanFiles) {
9606
9808
  const filePath = aiFile.getFilePath();
9607
9809
  const loggedPath = stripControlCharacters(filePath);
@@ -10728,6 +10930,15 @@ const FACTORYDROID_COMMANDS_DIR_PATH = join(FACTORYDROID_DIR, "commands");
10728
10930
  const FACTORYDROID_SKILLS_DIR_PATH = join(FACTORYDROID_DIR, "skills");
10729
10931
  const FACTORYDROID_DROIDS_DIR_PATH = join(FACTORYDROID_DIR, "droids");
10730
10932
  const FACTORYDROID_RULE_FILE_NAME = "AGENTS.md";
10933
+ /**
10934
+ * Factory Droid's design-guidelines instruction file: "Always-on design-system,
10935
+ * UX, visual, and interaction guidance", loaded separately from `AGENTS.md`'s
10936
+ * coding guidelines. Project scope only — Factory's docs describe root and
10937
+ * nested `DESIGN.md` files like `AGENTS.md`, but document no personal/global
10938
+ * home-directory equivalent.
10939
+ * @see https://docs.factory.ai/cli/configuration/agents-md
10940
+ */
10941
+ const FACTORYDROID_DESIGN_FILE_NAME = "DESIGN.md";
10731
10942
  const FACTORYDROID_MCP_FILE_NAME = "mcp.json";
10732
10943
  const FACTORYDROID_SETTINGS_FILE_NAME = "settings.json";
10733
10944
  const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
@@ -11250,6 +11461,21 @@ function stripStrings(_key, value) {
11250
11461
  //#endregion
11251
11462
  //#region src/features/shared/shared-config-gateway.ts
11252
11463
  /**
11464
+ * Upper bound on the number of values a shared config document may expand
11465
+ * to once every YAML alias is written out. Real config files hold a few
11466
+ * hundred values at most; even a large MCP server catalog stays orders of
11467
+ * magnitude below the limit.
11468
+ */
11469
+ const MAX_SHARED_CONFIG_VALUES = 1e5;
11470
+ /**
11471
+ * Upper bound on the total character count of the string leaves and keys a
11472
+ * shared config document may expand to. The value budget bounds how many
11473
+ * values are visited, but one long string aliased thousands of times fits
11474
+ * that budget while the duplicated output balloons; charging every visited
11475
+ * string's length separately bounds the output regardless of alias count.
11476
+ */
11477
+ const MAX_SHARED_CONFIG_STRING_CHARS = 4e6;
11478
+ /**
11253
11479
  * Rebuild a parsed document without its prototype-pollution keys.
11254
11480
  *
11255
11481
  * Every object is rebuilt, not just the ones that are already plain: a literal
@@ -11264,15 +11490,61 @@ function stripStrings(_key, value) {
11264
11490
  *
11265
11491
  * Dates are the one object the YAML and TOML parsers produce that is not a
11266
11492
  * mapping, so they are passed through rather than flattened into `{}`.
11493
+ *
11494
+ * The rebuild is bounded, because a YAML alias makes one parsed container
11495
+ * reachable from many keys and every alias is copied out independently (the
11496
+ * writers dump with `noRefs: true`, so memoizing here would only move the
11497
+ * blowup into serialization). A small "alias bomb" of nested anchors would
11498
+ * otherwise cost exponential time and memory, and a self-referencing anchor
11499
+ * would recurse until the stack overflowed — both reachable from a config
11500
+ * file committed to a cloned repository. The walk therefore charges every
11501
+ * value against {@link MAX_SHARED_CONFIG_VALUES}, every string and key
11502
+ * against {@link MAX_SHARED_CONFIG_STRING_CHARS}, caps nesting at
11503
+ * {@link MAX_SHARED_CONFIG_DEPTH}, and refuses a reference back to an
11504
+ * ancestor outright, each with a clear error instead of a hang or a crash.
11267
11505
  */
11268
11506
  function sanitizeSharedConfigValue(value) {
11269
- if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
11507
+ return sanitizeSharedConfigValueBounded(value, createBoundedWalk({
11508
+ subject: "Shared config",
11509
+ limits: {
11510
+ maxValues: MAX_SHARED_CONFIG_VALUES,
11511
+ maxStringChars: MAX_SHARED_CONFIG_STRING_CHARS,
11512
+ maxDepth: 64
11513
+ }
11514
+ }));
11515
+ }
11516
+ /**
11517
+ * Refuse a container that is already on the descent path. Unlike the
11518
+ * frontmatter cleaner, which drops such a cycle and keeps the rest of the
11519
+ * document, a shared config file is refused outright: silently dropping part
11520
+ * of a user's settings file would let a later write-back persist the loss.
11521
+ */
11522
+ function enterSharedConfigContainer(walk, container) {
11523
+ if (walk.isAncestor(container)) throw new Error("Shared config contains a value that refers back to itself (a circular YAML alias); refusing to process it");
11524
+ walk.enter(container);
11525
+ }
11526
+ function sanitizeSharedConfigValueBounded(value, walk) {
11527
+ if (typeof value === "string") {
11528
+ walk.chargeValue(value.length);
11529
+ return value;
11530
+ }
11531
+ walk.chargeValue();
11532
+ if (Array.isArray(value)) {
11533
+ enterSharedConfigContainer(walk, value);
11534
+ const items = value.map((item) => sanitizeSharedConfigValueBounded(item, walk));
11535
+ walk.leave(value);
11536
+ return items;
11537
+ }
11270
11538
  if (value === null || typeof value !== "object" || value instanceof Date) return value;
11539
+ enterSharedConfigContainer(walk, value);
11271
11540
  const result = {};
11272
11541
  for (const [key, nested] of Object.entries(value)) {
11542
+ walk.chargeChars(key.length);
11543
+ const sanitized = sanitizeSharedConfigValueBounded(nested, walk);
11273
11544
  if (isPrototypePollutionKey(key)) continue;
11274
- result[key] = sanitizeSharedConfigValue(nested);
11545
+ result[key] = sanitized;
11275
11546
  }
11547
+ walk.leave(value);
11276
11548
  return result;
11277
11549
  }
11278
11550
  /**
@@ -11301,7 +11573,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
11301
11573
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11302
11574
  }
11303
11575
  if (parsed === void 0 || parsed === null) return {};
11304
- const sanitized = sanitizeSharedConfigValue(parsed);
11576
+ let sanitized;
11577
+ try {
11578
+ sanitized = sanitizeSharedConfigValue(parsed);
11579
+ } catch (error) {
11580
+ throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11581
+ }
11305
11582
  if (!isPlainObject$1(sanitized)) {
11306
11583
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
11307
11584
  return {};
@@ -15627,6 +15904,10 @@ function toAllowedToolsArray(value) {
15627
15904
  * The spec types `compatibility` as a free-form string. An object from a legacy
15628
15905
  * rulesync input is flattened to `key: value` pairs instead of being emitted as
15629
15906
  * a YAML mapping, which conformant clients reject.
15907
+ *
15908
+ * Exported for `CrushSkill`, which requires the same bare-string shape (Crush's
15909
+ * Go struct types `Compatibility` as a plain `string`) and reuses this
15910
+ * implementation rather than maintaining a second, divergent copy.
15630
15911
  */
15631
15912
  function toCompatibilityString(value) {
15632
15913
  if (typeof value === "string") return value;
@@ -15635,6 +15916,10 @@ function toCompatibilityString(value) {
15635
15916
  /**
15636
15917
  * The spec types `metadata` as "a map from string keys to string values", so
15637
15918
  * non-string values (e.g. a YAML number `version: 1`) are stringified.
15919
+ *
15920
+ * Exported for `CrushSkill`, which requires the same `map[string]string`
15921
+ * shape (Crush's Go struct types `Metadata` that way) and reuses this
15922
+ * implementation rather than maintaining a second, divergent copy.
15638
15923
  */
15639
15924
  function toStringMetadata(metadata) {
15640
15925
  return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, stringifyValue(value)]));
@@ -18645,7 +18930,13 @@ var CommandsProcessor = class extends FeatureProcessor {
18645
18930
  if (!matchByBasename || flatOnly && dirname(key) !== ".") return [key];
18646
18931
  return [key, basename(key)];
18647
18932
  };
18648
- const seen = new Set(toolCommands.flatMap((command) => keysOf(command)));
18933
+ const claimedKeys = new ClaimedIdentities();
18934
+ const primarySource = paths.relativeDirPath;
18935
+ const secondarySource = "a secondary source";
18936
+ for (const command of toolCommands) for (const candidate of keysOf(command)) claimedKeys.claim({
18937
+ identity: candidate,
18938
+ source: primarySource
18939
+ });
18649
18940
  const additionalCommands = await factory.class.loadAdditionalImportFiles({
18650
18941
  outputRoot: this.outputRoot,
18651
18942
  global: this.global,
@@ -18653,11 +18944,26 @@ var CommandsProcessor = class extends FeatureProcessor {
18653
18944
  });
18654
18945
  for (const command of additionalCommands) {
18655
18946
  const key = command.getRelativeFilePath();
18656
- if (keysOf(command, true).some((candidate) => seen.has(candidate))) {
18657
- this.logger.warn(`Duplicate ${this.toolTarget} command "${key}" from a secondary source; keeping the one already loaded.`);
18947
+ const collision = [...new Set(keysOf(command, true))].map((candidate) => {
18948
+ const claimed = claimedKeys.claim({
18949
+ identity: candidate,
18950
+ source: secondarySource
18951
+ });
18952
+ return claimed === null ? void 0 : {
18953
+ candidate,
18954
+ claimed
18955
+ };
18956
+ }).find((hit) => hit !== void 0);
18957
+ if (collision) {
18958
+ const { candidate, claimed } = collision;
18959
+ if (claimed.spelling === candidate) this.logger.warn(`Duplicate ${this.toolTarget} command "${stripControlCharacters(key)}" from ${secondarySource}; keeping the one already loaded.`);
18960
+ 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.`);
18658
18961
  continue;
18659
18962
  }
18660
- for (const candidate of keysOf(command)) seen.add(candidate);
18963
+ for (const candidate of keysOf(command)) claimedKeys.claim({
18964
+ identity: candidate,
18965
+ source: secondarySource
18966
+ });
18661
18967
  toolCommands.push(command);
18662
18968
  }
18663
18969
  }
@@ -18947,6 +19253,21 @@ var AmpHooks = class AmpHooks extends ToolHooks {
18947
19253
  }
18948
19254
  };
18949
19255
  //#endregion
19256
+ //#region src/utils/own-lookup.ts
19257
+ /**
19258
+ * Read a key from a plain string map without walking its prototype chain.
19259
+ *
19260
+ * A bracket read on an object literal resolves inherited members too, so a
19261
+ * user-supplied key such as `toString` or `constructor` "succeeds" with an
19262
+ * `Object.prototype` function instead of falling through to the caller's
19263
+ * `?? fallback`. Hook adapters translate native event names this way from
19264
+ * `Object.entries()` over a config file, so route the read through here to keep
19265
+ * the fallback honest: only a key the map itself defines yields a value.
19266
+ */
19267
+ function lookupOwn({ record, key }) {
19268
+ return Object.hasOwn(record, key) ? record[key] : void 0;
19269
+ }
19270
+ //#endregion
18950
19271
  //#region src/utils/object.ts
18951
19272
  /**
18952
19273
  * Return a shallow copy of `obj` keeping only the entries whose value is
@@ -19331,7 +19652,10 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
19331
19652
  const warn = warnOnce(logger);
19332
19653
  const result = {};
19333
19654
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
19334
- const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
19655
+ const toolEventName = lookupOwn({
19656
+ record: converterConfig.canonicalToToolEventNames,
19657
+ key: eventName
19658
+ }) ?? eventName;
19335
19659
  const byMatcher = groupDefinitionsByMatcher({
19336
19660
  definitions,
19337
19661
  converterConfig
@@ -19759,7 +20083,10 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
19759
20083
  const warn = warnOnce(logger);
19760
20084
  const canonical = {};
19761
20085
  for (const [toolEventName, matcherEntries] of Object.entries(hooks)) {
19762
- const eventName = converterConfig.toolToCanonicalEventNames[toolEventName] ?? toolEventName;
20086
+ const eventName = lookupOwn({
20087
+ record: converterConfig.toolToCanonicalEventNames,
20088
+ key: toolEventName
20089
+ }) ?? toolEventName;
19763
20090
  if (!Array.isArray(matcherEntries)) continue;
19764
20091
  const defs = [];
19765
20092
  for (const rawEntry of matcherEntries) {
@@ -19815,7 +20142,10 @@ function flattenAntigravityHooks(parsed) {
19815
20142
  const flat = {};
19816
20143
  const addEvent = (event, entries) => {
19817
20144
  if (isPrototypePollutionKey(event) || !Array.isArray(entries)) return;
19818
- const existing = Object.hasOwn(flat, event) ? flat[event] : void 0;
20145
+ const existing = lookupOwn({
20146
+ record: flat,
20147
+ key: event
20148
+ });
19819
20149
  flat[event] = existing ? [...existing, ...entries] : [...entries];
19820
20150
  };
19821
20151
  for (const [key, value] of Object.entries(parsed)) if (Array.isArray(value)) addEvent(key, value);
@@ -20202,6 +20532,16 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20202
20532
  const paths = AugmentcodeHooks.getSettablePaths({ global });
20203
20533
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
20204
20534
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
20535
+ let existingHooks = {};
20536
+ try {
20537
+ const parsed = JSON.parse(existingContent);
20538
+ const candidate = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.hooks : void 0;
20539
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) existingHooks = candidate;
20540
+ } catch {
20541
+ existingHooks = {};
20542
+ }
20543
+ const nativeEventKeys = new Set(Object.values(CANONICAL_TO_AUGMENTCODE_EVENT_NAMES));
20544
+ const preservedHooks = Object.fromEntries(Object.entries(existingHooks).filter(([key]) => !nativeEventKeys.has(key)));
20205
20545
  const config = rulesyncHooks.getJson();
20206
20546
  const augmentHooks = canonicalToToolHooks({
20207
20547
  config,
@@ -20213,7 +20553,10 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20213
20553
  fileKey: sharedConfigFileKey(paths),
20214
20554
  feature: "hooks",
20215
20555
  existingContent,
20216
- patch: { hooks: augmentHooks },
20556
+ patch: { hooks: {
20557
+ ...preservedHooks,
20558
+ ...augmentHooks
20559
+ } },
20217
20560
  filePath
20218
20561
  });
20219
20562
  return new AugmentcodeHooks({
@@ -20971,7 +21314,10 @@ function canonicalToCopilotHooks(config) {
20971
21314
  };
20972
21315
  const copilot = {};
20973
21316
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
20974
- const copilotEventName = CANONICAL_TO_COPILOT_EVENT_NAMES[eventName] ?? eventName;
21317
+ const copilotEventName = lookupOwn({
21318
+ record: CANONICAL_TO_COPILOT_EVENT_NAMES,
21319
+ key: eventName
21320
+ }) ?? eventName;
20975
21321
  const entries = [];
20976
21322
  for (const def of definitions) {
20977
21323
  const hookType = def.type ?? "command";
@@ -21048,7 +21394,10 @@ function copilotHooksToCanonical(copilotHooks, logger) {
21048
21394
  if (copilotHooks === null || copilotHooks === void 0 || typeof copilotHooks !== "object") return {};
21049
21395
  const canonical = {};
21050
21396
  for (const [copilotEventName, hookEntries] of Object.entries(copilotHooks)) {
21051
- const eventName = COPILOT_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21397
+ const eventName = lookupOwn({
21398
+ record: COPILOT_TO_CANONICAL_EVENT_NAMES,
21399
+ key: copilotEventName
21400
+ }) ?? copilotEventName;
21052
21401
  if (!Array.isArray(hookEntries)) continue;
21053
21402
  const defs = [];
21054
21403
  for (const rawEntry of hookEntries) {
@@ -21318,7 +21667,10 @@ function canonicalToCopilotCliHooks(config, logger) {
21318
21667
  };
21319
21668
  const out = {};
21320
21669
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
21321
- const copilotEventName = CANONICAL_TO_COPILOTCLI_EVENT_NAMES[eventName] ?? eventName;
21670
+ const copilotEventName = lookupOwn({
21671
+ record: CANONICAL_TO_COPILOTCLI_EVENT_NAMES,
21672
+ key: eventName
21673
+ }) ?? eventName;
21322
21674
  const entries = buildCopilotCliEntriesForEvent({
21323
21675
  eventName,
21324
21676
  definitions,
@@ -21375,7 +21727,10 @@ function copilotCliHooksToCanonical(rawHooks, logger) {
21375
21727
  if (rawHooks === null || rawHooks === void 0 || typeof rawHooks !== "object") return {};
21376
21728
  const canonical = {};
21377
21729
  for (const [copilotEventName, hookEntries] of Object.entries(rawHooks)) {
21378
- const eventName = COPILOTCLI_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21730
+ const eventName = lookupOwn({
21731
+ record: COPILOTCLI_TO_CANONICAL_EVENT_NAMES,
21732
+ key: copilotEventName
21733
+ }) ?? copilotEventName;
21379
21734
  if (!Array.isArray(hookEntries)) continue;
21380
21735
  const defs = [];
21381
21736
  for (const rawEntry of hookEntries) {
@@ -21527,7 +21882,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21527
21882
  const mappedHooks = {};
21528
21883
  const cursorSupportedTypes = /* @__PURE__ */ new Set(["command", "prompt"]);
21529
21884
  for (const [eventName, defs] of Object.entries(mergedHooks)) {
21530
- const cursorEventName = CANONICAL_TO_CURSOR_EVENT_NAMES[eventName] ?? eventName;
21885
+ const cursorEventName = lookupOwn({
21886
+ record: CANONICAL_TO_CURSOR_EVENT_NAMES,
21887
+ key: eventName
21888
+ }) ?? eventName;
21531
21889
  const mappedDefs = defs.filter((def) => cursorSupportedTypes.has(def.type ?? "command")).map((def) => ({
21532
21890
  ...def.type !== void 0 && def.type !== null && { type: def.type },
21533
21891
  ...def.command !== void 0 && def.command !== null && { command: def.command },
@@ -21560,7 +21918,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21560
21918
  const cursorHooks = parsed.hooks ?? {};
21561
21919
  const canonicalHooks = {};
21562
21920
  for (const [cursorEventName, defs] of Object.entries(cursorHooks)) {
21563
- const eventName = CURSOR_TO_CANONICAL_EVENT_NAMES[cursorEventName] ?? cursorEventName;
21921
+ const eventName = lookupOwn({
21922
+ record: CURSOR_TO_CANONICAL_EVENT_NAMES,
21923
+ key: cursorEventName
21924
+ }) ?? cursorEventName;
21564
21925
  canonicalHooks[eventName] = defs;
21565
21926
  }
21566
21927
  const version = parsed.version ?? 1;
@@ -21630,7 +21991,10 @@ function canonicalToDeepagentsHooks(config) {
21630
21991
  const hooks = {};
21631
21992
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
21632
21993
  if (!supported.has(canonicalEvent)) continue;
21633
- const deepagentsEvent = CANONICAL_TO_DEEPAGENTS_EVENT_NAMES[canonicalEvent];
21994
+ const deepagentsEvent = lookupOwn({
21995
+ record: CANONICAL_TO_DEEPAGENTS_EVENT_NAMES,
21996
+ key: canonicalEvent
21997
+ });
21634
21998
  if (!deepagentsEvent) continue;
21635
21999
  for (const def of definitions) {
21636
22000
  if ((def.type ?? "command") !== "command") continue;
@@ -21659,7 +22023,10 @@ function canonicalToDeepagentsHooks(config) {
21659
22023
  function deepagentsToCanonicalHooks(hooks) {
21660
22024
  const canonical = {};
21661
22025
  for (const [deepagentsEvent, groups] of Object.entries(hooks)) {
21662
- const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
22026
+ const canonicalEvent = lookupOwn({
22027
+ record: DEEPAGENTS_TO_CANONICAL_EVENT_NAMES,
22028
+ key: deepagentsEvent
22029
+ });
21663
22030
  if (!canonicalEvent || !Array.isArray(groups)) continue;
21664
22031
  for (const group of groups) {
21665
22032
  if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
@@ -21692,7 +22059,10 @@ function deepagentsLegacyToCanonicalHooks(entries) {
21692
22059
  const command = argv.length === 3 && argv[0] === "bash" && argv[1] === "-c" ? String(argv[2] ?? "") : argv.join(" ");
21693
22060
  const events = Array.isArray(entry.events) ? entry.events : [];
21694
22061
  for (const legacyEvent of events) {
21695
- const canonicalEvent = typeof legacyEvent === "string" ? DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES[legacyEvent] : void 0;
22062
+ const canonicalEvent = typeof legacyEvent === "string" ? lookupOwn({
22063
+ record: DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES,
22064
+ key: legacyEvent
22065
+ }) : void 0;
21696
22066
  if (!canonicalEvent) continue;
21697
22067
  (canonical[canonicalEvent] ??= []).push({
21698
22068
  type: "command",
@@ -22413,7 +22783,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22413
22783
  const result = {};
22414
22784
  for (const [canonicalEvent, definitions] of Object.entries(config.hooks)) {
22415
22785
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22416
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22786
+ const nativeEvent = lookupOwn({
22787
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22788
+ key: canonicalEvent
22789
+ });
22417
22790
  if (nativeEvent) setHermesHookEntries({
22418
22791
  result,
22419
22792
  event: nativeEvent,
@@ -22424,7 +22797,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22424
22797
  }
22425
22798
  for (const [canonicalEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
22426
22799
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22427
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22800
+ const nativeEvent = lookupOwn({
22801
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22802
+ key: canonicalEvent
22803
+ });
22428
22804
  if (nativeEvent) setHermesHookEntries({
22429
22805
  result,
22430
22806
  event: nativeEvent,
@@ -22490,7 +22866,10 @@ function hermesHooksToCanonical(hooks) {
22490
22866
  for (const [nativeEvent, entries] of Object.entries(hooks)) {
22491
22867
  if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) continue;
22492
22868
  if (!isHermesHookEventEntry(nativeEvent, entries)) continue;
22493
- const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;
22869
+ const rulesyncEvent = lookupOwn({
22870
+ record: HERMESAGENT_TO_CANONICAL_EVENT_NAMES,
22871
+ key: nativeEvent
22872
+ }) ?? nativeEvent;
22494
22873
  const defs = entries.map((raw) => hermesEntryToDefinition({
22495
22874
  nativeEvent,
22496
22875
  raw
@@ -23127,7 +23506,10 @@ function canonicalToKimiCodeHooks({ config, toolOverrideHooks, trustedDirectory,
23127
23506
  const result = [];
23128
23507
  const nativeEvents = new Set(KIMI_CODE_NATIVE_HOOK_EVENTS);
23129
23508
  for (const [event, definitions] of Object.entries(buildEffectiveHooks(config, toolOverrideHooks))) {
23130
- const nativeEvent = CANONICAL_TO_KIMI_CODE_EVENT_NAMES[event] ?? event;
23509
+ const nativeEvent = lookupOwn({
23510
+ record: CANONICAL_TO_KIMI_CODE_EVENT_NAMES,
23511
+ key: event
23512
+ }) ?? event;
23131
23513
  if (!nativeEvents.has(nativeEvent)) {
23132
23514
  logger?.warn(`Kimi Code hooks: skipping unsupported event "${event}".`);
23133
23515
  continue;
@@ -23161,14 +23543,22 @@ function kimiCodeHooksToCanonical(hooks) {
23161
23543
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
23162
23544
  const entry = raw;
23163
23545
  if (typeof entry.event !== "string" || typeof entry.command !== "string") continue;
23164
- const event = KIMI_CODE_TO_CANONICAL_EVENT_NAMES[entry.event] ?? entry.event;
23546
+ const event = lookupOwn({
23547
+ record: KIMI_CODE_TO_CANONICAL_EVENT_NAMES,
23548
+ key: entry.event
23549
+ }) ?? entry.event;
23165
23550
  const definition = {
23166
23551
  type: "command",
23167
23552
  command: stripTrustedDirectoryWrapper(entry.command),
23168
23553
  ...typeof entry.matcher === "string" && { matcher: entry.matcher },
23169
23554
  ...typeof entry.timeout === "number" && { timeout: entry.timeout }
23170
23555
  };
23171
- (result[event] ??= []).push(definition);
23556
+ const list = lookupOwn({
23557
+ record: result,
23558
+ key: event
23559
+ }) ?? [];
23560
+ list.push(definition);
23561
+ result[event] = list;
23172
23562
  }
23173
23563
  return result;
23174
23564
  }
@@ -23371,7 +23761,13 @@ function canonicalToKiroIdeHooks(config) {
23371
23761
  };
23372
23762
  const entries = [];
23373
23763
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23374
- const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES[eventName] ?? eventName;
23764
+ const trigger = lookupOwn({
23765
+ record: CANONICAL_TO_KIRO_IDE_EVENT_NAMES,
23766
+ key: eventName
23767
+ }) ?? lookupOwn({
23768
+ record: KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES,
23769
+ key: eventName
23770
+ }) ?? eventName;
23375
23771
  entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
23376
23772
  }
23377
23773
  return entries;
@@ -23380,7 +23776,10 @@ function kiroIdeHooksToCanonical(entries) {
23380
23776
  const canonical = {};
23381
23777
  for (const entry of entries) {
23382
23778
  if (entry.trigger === void 0 || entry.action === void 0) continue;
23383
- const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
23779
+ const eventName = lookupOwn({
23780
+ record: KIRO_IDE_TO_CANONICAL_EVENT_NAMES,
23781
+ key: entry.trigger
23782
+ }) ?? entry.trigger;
23384
23783
  if (isPrototypePollutionKey(eventName)) continue;
23385
23784
  const def = {};
23386
23785
  if (entry.action.type === "command") {
@@ -23397,7 +23796,12 @@ function kiroIdeHooksToCanonical(entries) {
23397
23796
  if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
23398
23797
  if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
23399
23798
  if (entry.enabled === false) def.enabled = false;
23400
- (canonical[eventName] ??= []).push(def);
23799
+ const list = lookupOwn({
23800
+ record: canonical,
23801
+ key: eventName
23802
+ }) ?? [];
23803
+ list.push(def);
23804
+ canonical[eventName] = list;
23401
23805
  }
23402
23806
  return canonical;
23403
23807
  }
@@ -23578,7 +23982,10 @@ function canonicalToKiroHooks({ config, logger }) {
23578
23982
  };
23579
23983
  const kiro = {};
23580
23984
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23581
- const kiroEventName = CANONICAL_TO_KIRO_EVENT_NAMES[eventName] ?? eventName;
23985
+ const kiroEventName = lookupOwn({
23986
+ record: CANONICAL_TO_KIRO_EVENT_NAMES,
23987
+ key: eventName
23988
+ }) ?? eventName;
23582
23989
  const entries = buildKiroEntriesForEvent(definitions);
23583
23990
  if (entries.length > 0) if (kiro[kiroEventName]) kiro[kiroEventName].push(...entries);
23584
23991
  else kiro[kiroEventName] = entries;
@@ -23609,7 +24016,10 @@ function kiroHooksToCanonical(kiroHooks) {
23609
24016
  if (kiroHooks === null || kiroHooks === void 0 || typeof kiroHooks !== "object") return {};
23610
24017
  const canonical = {};
23611
24018
  for (const [kiroEventName, entries] of Object.entries(kiroHooks)) {
23612
- const eventName = KIRO_TO_CANONICAL_EVENT_NAMES[kiroEventName] ?? kiroEventName;
24019
+ const eventName = lookupOwn({
24020
+ record: KIRO_TO_CANONICAL_EVENT_NAMES,
24021
+ key: kiroEventName
24022
+ }) ?? kiroEventName;
23613
24023
  if (!Array.isArray(entries)) continue;
23614
24024
  const defs = [];
23615
24025
  for (const rawEntry of entries) {
@@ -24168,7 +24578,10 @@ function canonicalToQwencodeHooks(config, logger) {
24168
24578
  ]);
24169
24579
  const qwencode = {};
24170
24580
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
24171
- const qwencodeEventName = CANONICAL_TO_QWENCODE_EVENT_NAMES[eventName] ?? eventName;
24581
+ const qwencodeEventName = lookupOwn({
24582
+ record: CANONICAL_TO_QWENCODE_EVENT_NAMES,
24583
+ key: eventName
24584
+ }) ?? eventName;
24172
24585
  const byMatcher = /* @__PURE__ */ new Map();
24173
24586
  for (const def of definitions) {
24174
24587
  if (!qwencodeSupportedTypes.has(def.type ?? "command")) continue;
@@ -24273,7 +24686,10 @@ function qwencodeHooksToCanonical(qwencodeHooks) {
24273
24686
  if (qwencodeHooks === null || qwencodeHooks === void 0 || typeof qwencodeHooks !== "object") return {};
24274
24687
  const canonical = {};
24275
24688
  for (const [qwencodeEventName, matcherEntries] of Object.entries(qwencodeHooks)) {
24276
- const eventName = QWENCODE_TO_CANONICAL_EVENT_NAMES[qwencodeEventName] ?? qwencodeEventName;
24689
+ const eventName = lookupOwn({
24690
+ record: QWENCODE_TO_CANONICAL_EVENT_NAMES,
24691
+ key: qwencodeEventName
24692
+ }) ?? qwencodeEventName;
24277
24693
  if (!Array.isArray(matcherEntries)) continue;
24278
24694
  const defs = [];
24279
24695
  for (const rawEntry of matcherEntries) {
@@ -24388,7 +24804,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24388
24804
  const result = {};
24389
24805
  for (const [event, defs] of Object.entries(effectiveHooks)) {
24390
24806
  if (!SUPPORTED_REASONIX_EVENTS.has(event)) continue;
24391
- const reasonixEvent = CANONICAL_TO_REASONIX_EVENT_NAMES[event] ?? event;
24807
+ const reasonixEvent = lookupOwn({
24808
+ record: CANONICAL_TO_REASONIX_EVENT_NAMES,
24809
+ key: event
24810
+ }) ?? event;
24392
24811
  const isMatcherEvent = REASONIX_MATCHER_EVENTS.has(reasonixEvent);
24393
24812
  const entries = [];
24394
24813
  for (const def of defs) {
@@ -24401,7 +24820,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24401
24820
  if (typeof def.timeout === "number") entry.timeout = Math.round(def.timeout * 1e3);
24402
24821
  entries.push(entry);
24403
24822
  }
24404
- if (entries.length > 0) result[reasonixEvent] = [...result[reasonixEvent] ?? [], ...entries];
24823
+ if (entries.length > 0) result[reasonixEvent] = [...lookupOwn({
24824
+ record: result,
24825
+ key: reasonixEvent
24826
+ }) ?? [], ...entries];
24405
24827
  }
24406
24828
  return result;
24407
24829
  }
@@ -24414,7 +24836,10 @@ function reasonixHooksToCanonical(hooks) {
24414
24836
  if (hooks === null || hooks === void 0 || typeof hooks !== "object" || Array.isArray(hooks)) return canonical;
24415
24837
  for (const [reasonixEvent, rawEntries] of Object.entries(hooks)) {
24416
24838
  if (!Array.isArray(rawEntries)) continue;
24417
- const canonicalEvent = REASONIX_TO_CANONICAL_EVENT_NAMES[reasonixEvent] ?? reasonixEvent;
24839
+ const canonicalEvent = lookupOwn({
24840
+ record: REASONIX_TO_CANONICAL_EVENT_NAMES,
24841
+ key: reasonixEvent
24842
+ }) ?? reasonixEvent;
24418
24843
  const defs = [];
24419
24844
  for (const rawEntry of rawEntries) {
24420
24845
  if (rawEntry === null || typeof rawEntry !== "object" || Array.isArray(rawEntry)) continue;
@@ -24429,7 +24854,10 @@ function reasonixHooksToCanonical(hooks) {
24429
24854
  if (typeof entry.timeout === "number") def.timeout = entry.timeout / 1e3;
24430
24855
  defs.push(def);
24431
24856
  }
24432
- if (defs.length > 0) canonical[canonicalEvent] = [...canonical[canonicalEvent] ?? [], ...defs];
24857
+ if (defs.length > 0) canonical[canonicalEvent] = [...lookupOwn({
24858
+ record: canonical,
24859
+ key: canonicalEvent
24860
+ }) ?? [], ...defs];
24433
24861
  }
24434
24862
  return canonical;
24435
24863
  }
@@ -24557,7 +24985,10 @@ function canonicalToVibeHooks(config, toolOverride) {
24557
24985
  const hooks = [];
24558
24986
  for (const [event, defs] of Object.entries(effective)) {
24559
24987
  if (!SUPPORTED_VIBE_EVENTS.has(event)) continue;
24560
- const vibeEvent = CANONICAL_TO_VIBE_EVENT_NAMES[event] ?? event;
24988
+ const vibeEvent = lookupOwn({
24989
+ record: CANONICAL_TO_VIBE_EVENT_NAMES,
24990
+ key: event
24991
+ }) ?? event;
24561
24992
  let index = 0;
24562
24993
  for (const def of defs) {
24563
24994
  if ((def.type ?? "command") !== "command") continue;
@@ -24587,7 +25018,10 @@ function vibeEntryToCanonicalDef(raw) {
24587
25018
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
24588
25019
  if (vibeEvent === void 0) return null;
24589
25020
  if (isPrototypePollutionKey(vibeEvent)) return null;
24590
- const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
25021
+ const canonicalEvent = lookupOwn({
25022
+ record: VIBE_TO_CANONICAL_EVENT_NAMES,
25023
+ key: vibeEvent
25024
+ }) ?? vibeEvent;
24591
25025
  const def = { type: "command" };
24592
25026
  if (typeof entry.command === "string") def.command = entry.command;
24593
25027
  if (typeof entry.match === "string" && entry.match !== "" && entry.match !== "*") def.matcher = entry.match;
@@ -24612,7 +25046,10 @@ function vibeHooksToCanonical(parsed) {
24612
25046
  for (const raw of rawHooks) {
24613
25047
  const result = vibeEntryToCanonicalDef(raw);
24614
25048
  if (result === null) continue;
24615
- const list = canonical[result.canonicalEvent] ?? [];
25049
+ const list = lookupOwn({
25050
+ record: canonical,
25051
+ key: result.canonicalEvent
25052
+ }) ?? [];
24616
25053
  list.push(result.def);
24617
25054
  canonical[result.canonicalEvent] = list;
24618
25055
  }
@@ -25691,6 +26128,68 @@ var ClineIgnore = class ClineIgnore extends ToolIgnore {
25691
26128
  }
25692
26129
  };
25693
26130
  //#endregion
26131
+ //#region src/constants/crush-paths.ts
26132
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
26133
+ const CRUSH_GLOBAL_DIR = join(".config", "crush");
26134
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
26135
+ const CRUSH_SKILLS_PROJECT_DIR = join(".crush", "skills");
26136
+ const CRUSH_SKILLS_GLOBAL_DIR = join(CRUSH_GLOBAL_DIR, "skills");
26137
+ //#endregion
26138
+ //#region src/features/ignore/crush-ignore.ts
26139
+ /**
26140
+ * Ignore generator for Crush.
26141
+ *
26142
+ * Crush excludes files from tool access via a `.crushignore` file, read
26143
+ * hierarchically (root and any subdirectory, the same way it walks
26144
+ * `.gitignore`) using gitignore syntax. Crush documents no global/user-scope
26145
+ * ignore file, so this is project-only.
26146
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/fsext/fileutil.go
26147
+ */
26148
+ var CrushIgnore = class CrushIgnore extends ToolIgnore {
26149
+ static getSettablePaths() {
26150
+ return {
26151
+ relativeDirPath: ".",
26152
+ relativeFilePath: CRUSH_IGNORE_FILE_NAME
26153
+ };
26154
+ }
26155
+ toRulesyncIgnore() {
26156
+ return new RulesyncIgnore({
26157
+ outputRoot: ".",
26158
+ relativeDirPath: ".",
26159
+ relativeFilePath: RULESYNC_AIIGNORE_RELATIVE_FILE_PATH,
26160
+ fileContent: this.fileContent
26161
+ });
26162
+ }
26163
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
26164
+ const body = rulesyncIgnore.getFileContent();
26165
+ return new CrushIgnore({
26166
+ outputRoot,
26167
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26168
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26169
+ fileContent: body
26170
+ });
26171
+ }
26172
+ static async fromFile({ outputRoot = process.cwd(), validate = true }) {
26173
+ const fileContent = await readFileContent(join(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath));
26174
+ return new CrushIgnore({
26175
+ outputRoot,
26176
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26177
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26178
+ fileContent,
26179
+ validate
26180
+ });
26181
+ }
26182
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
26183
+ return new CrushIgnore({
26184
+ outputRoot,
26185
+ relativeDirPath,
26186
+ relativeFilePath,
26187
+ fileContent: "",
26188
+ validate: false
26189
+ });
26190
+ }
26191
+ };
26192
+ //#endregion
25694
26193
  //#region src/features/ignore/cursor-ignore.ts
25695
26194
  /**
25696
26195
  * Cursor ignore adapter.
@@ -26687,6 +27186,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
26687
27186
  ["claudecode", { class: ClaudecodeIgnore }],
26688
27187
  ["claudecode-legacy", { class: ClaudecodeIgnore }],
26689
27188
  ["cline", { class: ClineIgnore }],
27189
+ ["crush", { class: CrushIgnore }],
26690
27190
  ["cursor", { class: CursorIgnore }],
26691
27191
  ["hermesagent", { class: HermesagentIgnore }],
26692
27192
  ["junie", { class: JunieIgnore }],
@@ -27991,9 +28491,8 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
27991
28491
  throw new Error(`Failed to parse existing Codex CLI config at ${configTomlFilePath}: ${formatError(error)}`, { cause: error });
27992
28492
  }
27993
28493
  const strippedMcpServers = rulesyncMcp.getMcpServers();
27994
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
27995
28494
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
27996
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
28495
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
27997
28496
  return [serverName, {
27998
28497
  ...serverConfig,
27999
28498
  ...isRecord$1(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
@@ -30956,9 +31455,8 @@ var MusecodeMcp = class MusecodeMcp extends ToolMcp {
30956
31455
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
30957
31456
  const existingContent = await readFileContentOrNull(filePath) ?? "";
30958
31457
  const existing = parseMusecodeSettings(existingContent, filePath);
30959
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
30960
31458
  const converted = convertToMusecodeFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => {
30961
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
31459
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
30962
31460
  const mode = asMusecodeMode(isRecord$1(rawServer) ? rawServer.musecodeMode : void 0);
30963
31461
  return [serverName, {
30964
31462
  ...serverConfig,
@@ -31907,6 +32405,66 @@ async function readRovodevConfigYaml({ outputRoot }) {
31907
32405
  filePath: join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)
31908
32406
  });
31909
32407
  }
32408
+ /**
32409
+ * Decide the absolute path a configured `mcpConfigPath` would name in the
32410
+ * given scope, without yet checking whether that path stays inside it. Split
32411
+ * out of resolveRovodevMcpImportPath so each function's branching stays
32412
+ * within the project's complexity budget.
32413
+ */
32414
+ function resolveMcpConfigCandidatePath({ outputRoot, global, normalizedPath, configuredPath }) {
32415
+ if (global && normalizedPath.startsWith("~/")) return { path: resolve(outputRoot, normalizedPath.slice(2)) };
32416
+ 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 ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32417
+ if (isAbsolute(normalizedPath)) return { path: resolve(normalizedPath) };
32418
+ if (!global) return { path: resolve(outputRoot, normalizedPath) };
32419
+ 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 ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32420
+ }
32421
+ /**
32422
+ * Resolve the active Rovo Dev MCP config without following a pointer outside
32423
+ * the import scope. The implementation is deliberately separate from
32424
+ * fromFile: it keeps path-policy decisions testable without changing the
32425
+ * public ToolMcp contract.
32426
+ */
32427
+ async function resolveRovodevMcpImportPath({ outputRoot, global, config, logger }) {
32428
+ const fallback = {
32429
+ filePath: join(outputRoot, ROVODEV_DIR, ROVODEV_MCP_FILE_NAME),
32430
+ relativeDirPath: ROVODEV_DIR,
32431
+ relativeFilePath: ROVODEV_MCP_FILE_NAME
32432
+ };
32433
+ const configuredPath = (config && isRecord$1(config.mcp) ? config.mcp : {}).mcpConfigPath;
32434
+ if (configuredPath === void 0) {
32435
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is unset in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Importing ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which may not be the file Rovo Dev reads.`);
32436
+ return fallback;
32437
+ }
32438
+ if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
32439
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${join(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} must be a non-empty string. Importing ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32440
+ return fallback;
32441
+ }
32442
+ const normalizedPath = normalizeMcpConfigPathValue(configuredPath.trim());
32443
+ const candidateResult = resolveMcpConfigCandidatePath({
32444
+ outputRoot,
32445
+ global,
32446
+ normalizedPath,
32447
+ configuredPath
32448
+ });
32449
+ if ("rejectionMessage" in candidateResult) {
32450
+ logger?.warn(candidateResult.rejectionMessage);
32451
+ return fallback;
32452
+ }
32453
+ const candidatePath = candidateResult.path;
32454
+ const relativePath = relative(resolve(outputRoot), candidatePath);
32455
+ if (relativePath === "" || splitPathSegments(normalizedPath).includes("..") || pathEscapesRoot(relativePath) || await resolvedPathEscapesRoot({
32456
+ rootPath: outputRoot,
32457
+ targetPath: candidatePath
32458
+ })) {
32459
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)}, which is outside the import scope or traverses a symbolic link. Importing ${join(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32460
+ return fallback;
32461
+ }
32462
+ return {
32463
+ filePath: candidatePath,
32464
+ relativeDirPath: dirname(relativePath),
32465
+ relativeFilePath: basename(relativePath)
32466
+ };
32467
+ }
31910
32468
  function disabledNamesOf(config) {
31911
32469
  const mcpBlock = config && isRecord$1(config.mcp) ? config.mcp : {};
31912
32470
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
@@ -32012,6 +32570,25 @@ function envVarMcpFileSpellings({ fileName }) {
32012
32570
  return [`$HOME/${tail}`, `\${HOME}/${tail}`];
32013
32571
  }
32014
32572
  /**
32573
+ * Classify the existing `mcpConfigPath` without deciding how to report it.
32574
+ * Keep the known-file checks in this order: the generated file is valid in
32575
+ * either scope, while the documented default and environment-variable
32576
+ * spellings are global-only alternatives that need their own warnings.
32577
+ */
32578
+ function classifyExistingPointer({ existing, global, outputRoot }) {
32579
+ if (existing === void 0) return { kind: "unset" };
32580
+ const normalized = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32581
+ const namesFile = (fileName) => normalized !== void 0 && mcpFileSpellings({
32582
+ fileName,
32583
+ global,
32584
+ outputRoot
32585
+ }).includes(normalized);
32586
+ if (namesFile("mcp.json")) return { kind: "already-generated" };
32587
+ if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) return { kind: "documented-default" };
32588
+ if (global && normalized !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalized)) return { kind: "env-var-spelling" };
32589
+ return { kind: "unrelated" };
32590
+ }
32591
+ /**
32015
32592
  * Point `mcp.mcpConfigPath` at the `mcp.json` rulesync writes for this scope,
32016
32593
  * and report whether the block gained a value it did not already carry.
32017
32594
  *
@@ -32122,45 +32699,44 @@ function announcePointer({ global, logger }) {
32122
32699
  async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outputRoot, logger }) {
32123
32700
  const { pointer, configLabel, mcpLabel } = pointerLabels(global);
32124
32701
  const existing = existingMcp.mcpConfigPath;
32125
- const normalizedExisting = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32126
- const namesFile = (fileName) => normalizedExisting !== void 0 && mcpFileSpellings({
32127
- fileName,
32702
+ const classification = classifyExistingPointer({
32703
+ existing,
32128
32704
  global,
32129
32705
  outputRoot
32130
- }).includes(normalizedExisting);
32131
- const pointsAtGeneratedFile = namesFile(ROVODEV_MCP_FILE_NAME);
32706
+ });
32132
32707
  if (!hasLiveServers) {
32133
- 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"}.`);
32708
+ 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"}.`);
32134
32709
  return false;
32135
32710
  }
32136
- if (existing === void 0) {
32137
- const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32138
- if (displaced !== null) {
32139
- 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.`);
32140
- return false;
32711
+ switch (classification.kind) {
32712
+ case "unset": {
32713
+ const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32714
+ if (displaced !== null) {
32715
+ 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.`);
32716
+ return false;
32717
+ }
32718
+ existingMcp.mcpConfigPath = pointer;
32719
+ announcePointer({
32720
+ global,
32721
+ logger
32722
+ });
32723
+ return true;
32141
32724
  }
32142
- existingMcp.mcpConfigPath = pointer;
32143
- announcePointer({
32144
- global,
32145
- logger
32146
- });
32147
- return true;
32148
- }
32149
- if (pointsAtGeneratedFile) return false;
32150
- if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) {
32151
- await warnAtDocumentedDefault({
32152
- existing,
32153
- outputRoot,
32154
- logger
32155
- });
32156
- return false;
32157
- }
32158
- if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
32159
- 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.`);
32160
- return false;
32725
+ case "already-generated": return false;
32726
+ case "documented-default":
32727
+ await warnAtDocumentedDefault({
32728
+ existing,
32729
+ outputRoot,
32730
+ logger
32731
+ });
32732
+ return false;
32733
+ case "env-var-spelling":
32734
+ 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.`);
32735
+ return false;
32736
+ case "unrelated":
32737
+ 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}".`);
32738
+ return false;
32161
32739
  }
32162
- 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}".`);
32163
- return false;
32164
32740
  }
32165
32741
  /**
32166
32742
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
@@ -32202,14 +32778,20 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32202
32778
  relativeFilePath: ROVODEV_MCP_FILE_NAME
32203
32779
  };
32204
32780
  }
32205
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32206
- const paths = this.getSettablePaths({ global });
32207
- const json = parseRovodevMcpJson(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32781
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false, logger }) {
32782
+ const rovodevConfig = await readRovodevConfigYaml({ outputRoot });
32783
+ const paths = await resolveRovodevMcpImportPath({
32784
+ outputRoot,
32785
+ global,
32786
+ config: rovodevConfig,
32787
+ logger
32788
+ });
32789
+ const json = parseRovodevMcpJson(await readFileContentOrNull(paths.filePath) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32208
32790
  const newJson = {
32209
32791
  ...json,
32210
32792
  mcpServers: json.mcpServers ?? {}
32211
32793
  };
32212
- const disabledNames = disabledNamesOf(await readRovodevConfigYaml({ outputRoot }));
32794
+ const disabledNames = disabledNamesOf(rovodevConfig);
32213
32795
  if (disabledNames.length > 0 && isMcpServers(newJson.mcpServers)) {
32214
32796
  const servers = newJson.mcpServers;
32215
32797
  for (const name of disabledNames) {
@@ -32239,9 +32821,8 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32239
32821
  } catch {
32240
32822
  canWriteDisableToggle = false;
32241
32823
  }
32242
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
32243
32824
  const mcpServers = Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => {
32244
- const rawServer = isRecord$1(rawMcpServers) && Object.hasOwn(rawMcpServers, name) ? rawMcpServers[name] : void 0;
32825
+ const rawServer = rulesyncMcp.getRawMcpServer(name);
32245
32826
  const record = {
32246
32827
  ...server,
32247
32828
  ...readEnableInstructions(rawServer) && { rovodevEnableInstructions: true }
@@ -34229,6 +34810,518 @@ function convertAmpToRulesync({ disable, permissions }) {
34229
34810
  return { permission };
34230
34811
  }
34231
34812
  //#endregion
34813
+ //#region src/utils/glob.ts
34814
+ /**
34815
+ * Convert a glob-like pattern into an anchored regex source string.
34816
+ *
34817
+ * Only `*` (any run of characters) and `?` (one character) carry meaning;
34818
+ * every other regex metacharacter is escaped so it matches literally. The
34819
+ * result is anchored at both ends, because the callers ask "is this the whole
34820
+ * name?" rather than "does this appear somewhere in it?".
34821
+ *
34822
+ * Note that `[` and `]` are escaped along with everything else, so a bracket
34823
+ * class is a literal here while `matchesGlob` below reads it as a class. The
34824
+ * one caller wants exactly that: AugmentCode writes this source into its own
34825
+ * config as the tool's own shell-command regex, and never executes it, so it
34826
+ * has to say what the tool would read rather than what a glob means. Use
34827
+ * `matchesGlob` for an actual comparison.
34828
+ */
34829
+ function globToAnchoredRegexSource(glob) {
34830
+ let source = "";
34831
+ for (const char of glob) if (char === "*") source += ".*";
34832
+ else if (char === "?") source += ".";
34833
+ else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34834
+ else source += char;
34835
+ return `^${source}$`;
34836
+ }
34837
+ /**
34838
+ * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34839
+ * bracket is never closed — in which case it is an ordinary character.
34840
+ */
34841
+ function parseGlobClass(characters, start) {
34842
+ let index = start;
34843
+ const negated = characters[index] === "!" || characters[index] === "^";
34844
+ if (negated) index += 1;
34845
+ const members = /* @__PURE__ */ new Set();
34846
+ const ranges = [];
34847
+ let first = true;
34848
+ while (index < characters.length) {
34849
+ const character = characters[index] ?? "";
34850
+ if (character === "]" && !first) return {
34851
+ step: {
34852
+ kind: "class",
34853
+ negated,
34854
+ members,
34855
+ ranges
34856
+ },
34857
+ next: index + 1
34858
+ };
34859
+ first = false;
34860
+ const high = characters[index + 2];
34861
+ if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34862
+ ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34863
+ index += 3;
34864
+ continue;
34865
+ }
34866
+ members.add(character);
34867
+ index += 1;
34868
+ }
34869
+ }
34870
+ /** Split a glob into the steps `matchesGlob` walks. */
34871
+ function parseGlob(glob) {
34872
+ const characters = [...glob];
34873
+ const steps = [];
34874
+ let index = 0;
34875
+ let bracketsAreClosed = true;
34876
+ while (index < characters.length) {
34877
+ const character = characters[index] ?? "";
34878
+ index += 1;
34879
+ if (character === "*") {
34880
+ if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34881
+ continue;
34882
+ }
34883
+ if (character === "?") {
34884
+ steps.push({ kind: "any" });
34885
+ continue;
34886
+ }
34887
+ if (character === "[" && bracketsAreClosed) {
34888
+ const parsed = parseGlobClass(characters, index);
34889
+ if (parsed === void 0) bracketsAreClosed = false;
34890
+ else {
34891
+ steps.push(parsed.step);
34892
+ index = parsed.next;
34893
+ continue;
34894
+ }
34895
+ }
34896
+ steps.push({
34897
+ kind: "literal",
34898
+ character
34899
+ });
34900
+ }
34901
+ return steps;
34902
+ }
34903
+ function matchesGlobStep(step, character) {
34904
+ if (step.kind === "star") return false;
34905
+ if (step.kind === "any") return true;
34906
+ if (step.kind === "literal") return step.character === character;
34907
+ const code = character.codePointAt(0) ?? 0;
34908
+ const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34909
+ return step.negated ? !admitted : admitted;
34910
+ }
34911
+ /** Whether two single-character steps can both match one same character. */
34912
+ function stepsShareACharacter(left, right) {
34913
+ if (left.kind === "any" || right.kind === "any") return true;
34914
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34915
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34916
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34917
+ return true;
34918
+ }
34919
+ /** Whether every step from `index` on can match the empty string. */
34920
+ function isAllStars(steps, index) {
34921
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34922
+ return true;
34923
+ }
34924
+ /**
34925
+ * The most work one intersection walk will do, counted in cells times the cost
34926
+ * of one. Past it the two patterns are reported as intersecting without being
34927
+ * walked: the product of two lengths grows quadratically, and a pattern long
34928
+ * enough to reach this is pathological rather than a command anybody typed.
34929
+ * Answering `true` withholds an `allow`, which is the direction that fails
34930
+ * closed.
34931
+ */
34932
+ const MAX_INTERSECTION_CELLS = 1e6;
34933
+ /**
34934
+ * The most work a whole run of comparisons will do. A caller holding R
34935
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34936
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34937
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34938
+ * that together take minutes. The shared budget is spent down across the run
34939
+ * and, once it is gone, every remaining pair is reported as intersecting —
34940
+ * again the direction that withholds an `allow` rather than writing one.
34941
+ */
34942
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34943
+ /**
34944
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34945
+ * filling the two rows the table is held in, and collecting the answer.
34946
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34947
+ * one-step patterns walks a single cell, so n short restrictions against n
34948
+ * short allow rules is n squared comparisons that never spend the budget down
34949
+ * however many of them there are. Charging a floor per pair puts pair count and
34950
+ * walk length on the same exhaustible resource.
34951
+ *
34952
+ * For the short patterns of an ordinary config the floor is the whole charge,
34953
+ * which lowers how many pairs a run compares from around a million to about
34954
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34955
+ * that line withholds every allow it has not yet compared, the same fail-closed
34956
+ * answer exhaustion gives everywhere else.
34957
+ */
34958
+ const INTERSECTION_PAIR_COST = 64;
34959
+ /**
34960
+ * A budget for one caller's run of comparisons. Hand the same one to every
34961
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34962
+ * config — so the run as a whole stays bounded rather than only each pair in
34963
+ * it.
34964
+ */
34965
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34966
+ return { remaining };
34967
+ }
34968
+ /**
34969
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34970
+ * the same pattern against a whole list parses it once and reuses the result.
34971
+ */
34972
+ function parseGlobPattern(glob) {
34973
+ const steps = parseGlob(glob);
34974
+ return {
34975
+ steps,
34976
+ maxRanges: maxRangeCount(steps)
34977
+ };
34978
+ }
34979
+ /**
34980
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
34981
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
34982
+ * thousands of them turns a walk that looks affordable by cell count alone into
34983
+ * a quadratic one — which is why the budget is spent on cells times this rather
34984
+ * than on cells.
34985
+ */
34986
+ function maxRangeCount(steps) {
34987
+ let most = 0;
34988
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34989
+ return most;
34990
+ }
34991
+ /**
34992
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
34993
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
34994
+ * Once that budget is exhausted every further pair answers `true` without being
34995
+ * walked, so a caller reading the answer as a reason to restrict stays on the
34996
+ * safe side.
34997
+ */
34998
+ function parsedGlobsIntersect(left, right, budget) {
34999
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
35000
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
35001
+ const cost = rows.length * columns.length * cellCost;
35002
+ if (cost > MAX_INTERSECTION_CELLS) return true;
35003
+ if (budget !== void 0) {
35004
+ const charge = cost + INTERSECTION_PAIR_COST;
35005
+ if (charge > budget.remaining) {
35006
+ budget.remaining = 0;
35007
+ return true;
35008
+ }
35009
+ budget.remaining -= charge;
35010
+ }
35011
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
35012
+ for (let i = rows.length - 1; i >= 0; i--) {
35013
+ const row = Array.from({ length: columns.length + 1 }, () => false);
35014
+ row[columns.length] = isAllStars(rows, i);
35015
+ for (let j = columns.length - 1; j >= 0; j--) {
35016
+ const rowStep = rows[i];
35017
+ const columnStep = columns[j];
35018
+ if (rowStep === void 0 || columnStep === void 0) continue;
35019
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
35020
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
35021
+ continue;
35022
+ }
35023
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
35024
+ }
35025
+ next = row;
35026
+ }
35027
+ return next[0] ?? false;
35028
+ }
35029
+ //#endregion
35030
+ //#region src/features/permissions/shell-command-categories.ts
35031
+ /** The canonical category that names a shell command's permissions. */
35032
+ const SHELL_PERMISSION_CATEGORY = "bash";
35033
+ /**
35034
+ * Collect the canonical rules that govern shell commands, for the adapters
35035
+ * whose tool models commands and nothing else.
35036
+ *
35037
+ * The `bash` category contributes every rule. The all-tools `*` category
35038
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
35039
+ * written there covers shell commands too, and dropping it inverts the
35040
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
35041
+ * an adapter that reads only `bash` auto-approves the very command the file
35042
+ * denies.
35043
+ *
35044
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35045
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
35046
+ * carrying it in the restricting direction only over-restricts, while carrying
35047
+ * it in the permissive direction would grant something the author never said
35048
+ * about commands. Both directions therefore fail closed.
35049
+ */
35050
+ function collectShellCommandRules(permission) {
35051
+ const rules = [];
35052
+ const foreignRestrictingCategories = [];
35053
+ const ignoredAllToolsAllowPatterns = [];
35054
+ for (const [category, categoryRules] of Object.entries(permission)) {
35055
+ if (category === "bash") {
35056
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35057
+ pattern,
35058
+ action,
35059
+ fromAllToolsCategory: false
35060
+ });
35061
+ continue;
35062
+ }
35063
+ if (category === "*") {
35064
+ for (const [pattern, action] of Object.entries(categoryRules)) {
35065
+ if (action === "allow") {
35066
+ ignoredAllToolsAllowPatterns.push(pattern);
35067
+ continue;
35068
+ }
35069
+ rules.push({
35070
+ pattern,
35071
+ action,
35072
+ fromAllToolsCategory: true
35073
+ });
35074
+ }
35075
+ continue;
35076
+ }
35077
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35078
+ }
35079
+ return {
35080
+ rules,
35081
+ foreignRestrictingCategories,
35082
+ ignoredAllToolsAllowPatterns
35083
+ };
35084
+ }
35085
+ /**
35086
+ * Build the test an adapter applies to an `allow` pattern before writing it:
35087
+ * which restrictions it cannot write name some of the same commands? The
35088
+ * answer is the list of those restrictions — empty when the `allow` may be
35089
+ * written — so a caller can report both the allow rules it withheld and the
35090
+ * restrictions that withheld nothing.
35091
+ *
35092
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35093
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35094
+ * asking whether any one command matches both. Width does not enter into it: an
35095
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35096
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35097
+ * on every `git ... --force` command even though neither pattern covers the
35098
+ * other's spelling. Comparing only identical spellings would let the most
35099
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35100
+ *
35101
+ * Identical spellings are still compared as strings first, as a shortcut past
35102
+ * the walk for the commonest case.
35103
+ *
35104
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
35105
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
35106
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
35107
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
35108
+ * every tool, so it is a glob already — and is compared as it stands. The
35109
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
35110
+ * withholds an allow rather than writing one the config restricts — see
35111
+ * `warpCommandPatternToGlob`.
35112
+ */
35113
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35114
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35115
+ pattern,
35116
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35117
+ }));
35118
+ return (allowPattern) => {
35119
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35120
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35121
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35122
+ };
35123
+ }
35124
+ /**
35125
+ * Which of the given all-tools `*` restrictions look like they may not name a
35126
+ * command at all — the question a `deny` and an `ask` written there both raise.
35127
+ *
35128
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35129
+ * rules has nothing to withhold, and a pattern the author also wrote under
35130
+ * `bash` is a command on their own word. Both are excluded, so what remains is
35131
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35132
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
35133
+ *
35134
+ * A `bash` restriction never belongs here: it names a command by construction,
35135
+ * so overlapping no allow rule says nothing is wrong with it.
35136
+ */
35137
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35138
+ if (!rules.some(({ action }) => action === "allow")) return [];
35139
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35140
+ return uniq(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35141
+ }
35142
+ /**
35143
+ * Split shell-command rules into the allow and deny lists of a tool that models
35144
+ * commands with those two tiers and nothing else.
35145
+ *
35146
+ * `ask` has no list of its own — such a tool already prompts for whatever it
35147
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35148
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
35149
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
35150
+ * about would answer the prompt the author wanted.
35151
+ *
35152
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35153
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
35154
+ * regular expressions rather than globs, and writing any denylist **replaces**
35155
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35156
+ * the tool's own protection for a rule that matches no command. Where the deny
35157
+ * cannot be written it withholds the allow rules it covers instead, which
35158
+ * restricts in the same direction without touching the denylist.
35159
+ *
35160
+ * A `bash` deny withholds nothing: it names a command by construction, so the
35161
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35162
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35163
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35164
+ * even where it is written: a pattern under `*` need not name a command —
35165
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35166
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
35167
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35168
+ * command pattern is reported; failing open would not be.
35169
+ *
35170
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35171
+ * patterns are not globs.
35172
+ */
35173
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35174
+ const deny = [];
35175
+ const unwrittenDenyPatterns = [];
35176
+ const restrictions = [];
35177
+ const writtenAllToolsDenyPatterns = [];
35178
+ const allToolsAskPatterns = [];
35179
+ for (const rule of rules) {
35180
+ const { pattern, action, fromAllToolsCategory } = rule;
35181
+ if (action === "allow") continue;
35182
+ if (action !== "deny") {
35183
+ restrictions.push(rule);
35184
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35185
+ continue;
35186
+ }
35187
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
35188
+ deny.push(pattern);
35189
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35190
+ } else unwrittenDenyPatterns.push(pattern);
35191
+ if (fromAllToolsCategory) restrictions.push(rule);
35192
+ }
35193
+ const budget = createIntersectionBudget();
35194
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35195
+ normalizePattern,
35196
+ budget
35197
+ });
35198
+ const allow = [];
35199
+ const shadowedAllowPatterns = [];
35200
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35201
+ for (const { pattern, action } of rules) {
35202
+ if (action !== "allow") continue;
35203
+ const shadowing = shadowingRestrictions(pattern);
35204
+ if (shadowing.length > 0) {
35205
+ shadowedAllowPatterns.push(pattern);
35206
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35207
+ continue;
35208
+ }
35209
+ allow.push(pattern);
35210
+ }
35211
+ return {
35212
+ allow,
35213
+ deny,
35214
+ shadowedAllowPatterns,
35215
+ unwrittenDenyPatterns,
35216
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35217
+ rules,
35218
+ allToolsPatterns: writtenAllToolsDenyPatterns,
35219
+ withholdingPatterns
35220
+ }),
35221
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35222
+ rules,
35223
+ allToolsPatterns: allToolsAskPatterns,
35224
+ withholdingPatterns
35225
+ }),
35226
+ intersectionBudgetExhausted: budget.remaining === 0
35227
+ };
35228
+ }
35229
+ /**
35230
+ * Report, for one command-only tool, every canonical rule its two lists could
35231
+ * not carry. Every command-only adapter shares this reporting, so a rule
35232
+ * dropped in one is worded the same way in all.
35233
+ */
35234
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35235
+ 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.`);
35236
+ 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.`);
35237
+ 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.`);
35238
+ 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.`);
35239
+ 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.`);
35240
+ 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.`);
35241
+ 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.`);
35242
+ }
35243
+ function resolveShellCommandState(permission, writesAllToolsDeny) {
35244
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35245
+ const partitioned = partitionCommandRules({
35246
+ rules,
35247
+ writesAllToolsDeny
35248
+ });
35249
+ return {
35250
+ allow: partitioned.allow,
35251
+ deny: partitioned.deny,
35252
+ bash: bashRulesHonoringAllTools(permission),
35253
+ foreignRestrictingCategories,
35254
+ ignoredAllToolsAllowPatterns,
35255
+ shadowedAllowPatterns: partitioned.shadowedAllowPatterns,
35256
+ unwrittenDenyPatterns: partitioned.unwrittenDenyPatterns,
35257
+ unenforcedAllToolsDenyPatterns: partitioned.unenforcedAllToolsDenyPatterns,
35258
+ unenforcedAllToolsAskPatterns: partitioned.unenforcedAllToolsAskPatterns,
35259
+ intersectionBudgetExhausted: partitioned.intersectionBudgetExhausted
35260
+ };
35261
+ }
35262
+ /**
35263
+ * Collect shell-command allow/deny lists the way the command-only adapters do,
35264
+ * and report every restriction the surface cannot carry.
35265
+ */
35266
+ function resolveShellCommandLists({ permission, writesAllToolsDeny, toolLabel, surfaceLabel, logger }) {
35267
+ const resolved = resolveShellCommandState(permission, writesAllToolsDeny);
35268
+ warnAboutUnwrittenCommandRules({
35269
+ toolLabel,
35270
+ surfaceLabel,
35271
+ foreignRestrictingCategories: resolved.foreignRestrictingCategories,
35272
+ shadowedAllowPatterns: resolved.shadowedAllowPatterns,
35273
+ unwrittenDenyPatterns: resolved.unwrittenDenyPatterns,
35274
+ unenforcedAllToolsDenyPatterns: resolved.unenforcedAllToolsDenyPatterns,
35275
+ unenforcedAllToolsAskPatterns: resolved.unenforcedAllToolsAskPatterns,
35276
+ ignoredAllToolsAllowPatterns: resolved.ignoredAllToolsAllowPatterns,
35277
+ intersectionBudgetExhausted: resolved.intersectionBudgetExhausted,
35278
+ logger
35279
+ });
35280
+ return {
35281
+ allow: resolved.allow,
35282
+ deny: resolved.deny,
35283
+ bash: resolved.bash
35284
+ };
35285
+ }
35286
+ /**
35287
+ * The `bash` category after all-tools `*` restrictions have been applied. A
35288
+ * `deny`/`ask` written under `*` covers shell commands too, so a bash `allow`
35289
+ * it overlaps is withheld, a `*` deny is copied in, and a `*` ask is copied in
35290
+ * wherever `bash` says nothing about that exact pattern yet — otherwise it
35291
+ * would vanish from the resolved category entirely rather than falling back to
35292
+ * a tier that still prompts. An existing `bash` entry for the same pattern is
35293
+ * never downgraded by a `*` ask (a bash `allow` was already dropped above, and
35294
+ * a bash `deny`/`ask` there is at least as strict already).
35295
+ */
35296
+ function bashRulesHonoringAllTools(permission) {
35297
+ const { rules } = collectShellCommandRules(permission);
35298
+ const allToolsRestrictions = rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory);
35299
+ const shadowingRestrictions = createShadowingRestrictionsTest(allToolsRestrictions);
35300
+ const bash = { ...permission.bash };
35301
+ for (const [pattern, action] of Object.entries(bash)) if (action === "allow" && shadowingRestrictions(pattern).length > 0) delete bash[pattern];
35302
+ for (const { pattern, action } of allToolsRestrictions) {
35303
+ if (isPrototypePollutionKey(pattern)) continue;
35304
+ if (action === "deny") {
35305
+ if (bash[pattern] !== "ask") bash[pattern] = "deny";
35306
+ continue;
35307
+ }
35308
+ if (bash[pattern] === void 0) bash[pattern] = "ask";
35309
+ }
35310
+ return bash;
35311
+ }
35312
+ /**
35313
+ * Return a permission block whose `bash` category honors all-tools `*`
35314
+ * restrictions. Other categories are unchanged, so adapters that already model
35315
+ * `*` keep doing so.
35316
+ */
35317
+ function honorAllToolsOnBash(permission) {
35318
+ if (permission.bash === void 0) return permission;
35319
+ return {
35320
+ ...permission,
35321
+ bash: bashRulesHonoringAllTools(permission)
35322
+ };
35323
+ }
35324
+ //#endregion
34232
35325
  //#region src/features/permissions/antigravity-cli-permissions.ts
34233
35326
  /**
34234
35327
  * Top-level `~/.gemini/antigravity-cli/settings.json` keys the `antigravity-cli`
@@ -34476,7 +35569,7 @@ function convertRulesyncToAntigravityCliPermissions(config) {
34476
35569
  const allow = [];
34477
35570
  const ask = [];
34478
35571
  const deny = [];
34479
- for (const [category, rules] of Object.entries(config.permission)) {
35572
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34480
35573
  const cliToolName = toAntigravityCliToolName(category);
34481
35574
  for (const [pattern, action] of Object.entries(rules)) {
34482
35575
  const entry = buildPermissionEntry$1(cliToolName, pattern);
@@ -34687,7 +35780,7 @@ function convertRulesyncToAntigravityIdePermissions(config) {
34687
35780
  const allow = [];
34688
35781
  const ask = [];
34689
35782
  const deny = [];
34690
- for (const [category, rules] of Object.entries(config.permission)) {
35783
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34691
35784
  const action = toIdeAction(category);
34692
35785
  for (const [pattern, permissionAction] of Object.entries(rules)) {
34693
35786
  const entry = buildPermissionEntry(action, pattern);
@@ -34727,223 +35820,6 @@ function convertAntigravityIdeToRulesyncPermissions(params) {
34727
35820
  return { permission };
34728
35821
  }
34729
35822
  //#endregion
34730
- //#region src/utils/glob.ts
34731
- /**
34732
- * Convert a glob-like pattern into an anchored regex source string.
34733
- *
34734
- * Only `*` (any run of characters) and `?` (one character) carry meaning;
34735
- * every other regex metacharacter is escaped so it matches literally. The
34736
- * result is anchored at both ends, because the callers ask "is this the whole
34737
- * name?" rather than "does this appear somewhere in it?".
34738
- *
34739
- * Note that `[` and `]` are escaped along with everything else, so a bracket
34740
- * class is a literal here while `matchesGlob` below reads it as a class. The
34741
- * one caller wants exactly that: AugmentCode writes this source into its own
34742
- * config as the tool's own shell-command regex, and never executes it, so it
34743
- * has to say what the tool would read rather than what a glob means. Use
34744
- * `matchesGlob` for an actual comparison.
34745
- */
34746
- function globToAnchoredRegexSource(glob) {
34747
- let source = "";
34748
- for (const char of glob) if (char === "*") source += ".*";
34749
- else if (char === "?") source += ".";
34750
- else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34751
- else source += char;
34752
- return `^${source}$`;
34753
- }
34754
- /**
34755
- * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34756
- * bracket is never closed — in which case it is an ordinary character.
34757
- */
34758
- function parseGlobClass(characters, start) {
34759
- let index = start;
34760
- const negated = characters[index] === "!" || characters[index] === "^";
34761
- if (negated) index += 1;
34762
- const members = /* @__PURE__ */ new Set();
34763
- const ranges = [];
34764
- let first = true;
34765
- while (index < characters.length) {
34766
- const character = characters[index] ?? "";
34767
- if (character === "]" && !first) return {
34768
- step: {
34769
- kind: "class",
34770
- negated,
34771
- members,
34772
- ranges
34773
- },
34774
- next: index + 1
34775
- };
34776
- first = false;
34777
- const high = characters[index + 2];
34778
- if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34779
- ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34780
- index += 3;
34781
- continue;
34782
- }
34783
- members.add(character);
34784
- index += 1;
34785
- }
34786
- }
34787
- /** Split a glob into the steps `matchesGlob` walks. */
34788
- function parseGlob(glob) {
34789
- const characters = [...glob];
34790
- const steps = [];
34791
- let index = 0;
34792
- let bracketsAreClosed = true;
34793
- while (index < characters.length) {
34794
- const character = characters[index] ?? "";
34795
- index += 1;
34796
- if (character === "*") {
34797
- if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34798
- continue;
34799
- }
34800
- if (character === "?") {
34801
- steps.push({ kind: "any" });
34802
- continue;
34803
- }
34804
- if (character === "[" && bracketsAreClosed) {
34805
- const parsed = parseGlobClass(characters, index);
34806
- if (parsed === void 0) bracketsAreClosed = false;
34807
- else {
34808
- steps.push(parsed.step);
34809
- index = parsed.next;
34810
- continue;
34811
- }
34812
- }
34813
- steps.push({
34814
- kind: "literal",
34815
- character
34816
- });
34817
- }
34818
- return steps;
34819
- }
34820
- function matchesGlobStep(step, character) {
34821
- if (step.kind === "star") return false;
34822
- if (step.kind === "any") return true;
34823
- if (step.kind === "literal") return step.character === character;
34824
- const code = character.codePointAt(0) ?? 0;
34825
- const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34826
- return step.negated ? !admitted : admitted;
34827
- }
34828
- /** Whether two single-character steps can both match one same character. */
34829
- function stepsShareACharacter(left, right) {
34830
- if (left.kind === "any" || right.kind === "any") return true;
34831
- if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34832
- if (left.kind === "literal") return matchesGlobStep(right, left.character);
34833
- if (right.kind === "literal") return matchesGlobStep(left, right.character);
34834
- return true;
34835
- }
34836
- /** Whether every step from `index` on can match the empty string. */
34837
- function isAllStars(steps, index) {
34838
- for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34839
- return true;
34840
- }
34841
- /**
34842
- * The most work one intersection walk will do, counted in cells times the cost
34843
- * of one. Past it the two patterns are reported as intersecting without being
34844
- * walked: the product of two lengths grows quadratically, and a pattern long
34845
- * enough to reach this is pathological rather than a command anybody typed.
34846
- * Answering `true` withholds an `allow`, which is the direction that fails
34847
- * closed.
34848
- */
34849
- const MAX_INTERSECTION_CELLS = 1e6;
34850
- /**
34851
- * The most work a whole run of comparisons will do. A caller holding R
34852
- * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34853
- * bounds none of that: a hundred restrictions against a hundred allow rules,
34854
- * each pattern just under the per-pair cap, is ten thousand affordable walks
34855
- * that together take minutes. The shared budget is spent down across the run
34856
- * and, once it is gone, every remaining pair is reported as intersecting —
34857
- * again the direction that withholds an `allow` rather than writing one.
34858
- */
34859
- const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34860
- /**
34861
- * What a pair costs on top of the cells it walks: the call itself, sizing and
34862
- * filling the two rows the table is held in, and collecting the answer.
34863
- * Charging only cells would leave the *number* of pairs unbounded — a pair of
34864
- * one-step patterns walks a single cell, so n short restrictions against n
34865
- * short allow rules is n squared comparisons that never spend the budget down
34866
- * however many of them there are. Charging a floor per pair puts pair count and
34867
- * walk length on the same exhaustible resource.
34868
- *
34869
- * For the short patterns of an ordinary config the floor is the whole charge,
34870
- * which lowers how many pairs a run compares from around a million to about
34871
- * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34872
- * that line withholds every allow it has not yet compared, the same fail-closed
34873
- * answer exhaustion gives everywhere else.
34874
- */
34875
- const INTERSECTION_PAIR_COST = 64;
34876
- /**
34877
- * A budget for one caller's run of comparisons. Hand the same one to every
34878
- * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34879
- * config — so the run as a whole stays bounded rather than only each pair in
34880
- * it.
34881
- */
34882
- function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34883
- return { remaining };
34884
- }
34885
- /**
34886
- * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34887
- * the same pattern against a whole list parses it once and reuses the result.
34888
- */
34889
- function parseGlobPattern(glob) {
34890
- const steps = parseGlob(glob);
34891
- return {
34892
- steps,
34893
- maxRanges: maxRangeCount(steps)
34894
- };
34895
- }
34896
- /**
34897
- * What one cell can cost, as a multiplier on the cell count. A literal met by a
34898
- * `[a-z...]` class walks that class's ranges, so a single class carrying
34899
- * thousands of them turns a walk that looks affordable by cell count alone into
34900
- * a quadratic one — which is why the budget is spent on cells times this rather
34901
- * than on cells.
34902
- */
34903
- function maxRangeCount(steps) {
34904
- let most = 0;
34905
- for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34906
- return most;
34907
- }
34908
- /**
34909
- * `globsIntersect` for two globs already parsed, optionally spending a budget
34910
- * shared with the rest of the caller's run — see `createIntersectionBudget`.
34911
- * Once that budget is exhausted every further pair answers `true` without being
34912
- * walked, so a caller reading the answer as a reason to restrict stays on the
34913
- * safe side.
34914
- */
34915
- function parsedGlobsIntersect(left, right, budget) {
34916
- const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34917
- const cellCost = 1 + left.maxRanges + right.maxRanges;
34918
- const cost = rows.length * columns.length * cellCost;
34919
- if (cost > MAX_INTERSECTION_CELLS) return true;
34920
- if (budget !== void 0) {
34921
- const charge = cost + INTERSECTION_PAIR_COST;
34922
- if (charge > budget.remaining) {
34923
- budget.remaining = 0;
34924
- return true;
34925
- }
34926
- budget.remaining -= charge;
34927
- }
34928
- let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34929
- for (let i = rows.length - 1; i >= 0; i--) {
34930
- const row = Array.from({ length: columns.length + 1 }, () => false);
34931
- row[columns.length] = isAllStars(rows, i);
34932
- for (let j = columns.length - 1; j >= 0; j--) {
34933
- const rowStep = rows[i];
34934
- const columnStep = columns[j];
34935
- if (rowStep === void 0 || columnStep === void 0) continue;
34936
- if (rowStep.kind === "star" || columnStep.kind === "star") {
34937
- row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34938
- continue;
34939
- }
34940
- row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
34941
- }
34942
- next = row;
34943
- }
34944
- return next[0] ?? false;
34945
- }
34946
- //#endregion
34947
35823
  //#region src/features/permissions/augmentcode-permissions.ts
34948
35824
  const moduleLogger$2 = fallbackLogger;
34949
35825
  z.enum([
@@ -35195,6 +36071,7 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35195
36071
  const basicExistingEntries = existingEntries.filter((entry) => !isSpecialEntry(entry));
35196
36072
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
35197
36073
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
36074
+ if (entry.toolName === "*") return false;
35198
36075
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
35199
36076
  if (entry.permission.type === "deny") {
35200
36077
  const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
@@ -35272,7 +36149,18 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35272
36149
  };
35273
36150
  function convertRulesyncToAugmentEntries({ config, logger }) {
35274
36151
  const entries = [];
35275
- for (const [category, rules] of Object.entries(config.permission)) {
36152
+ const resolvedBashRules = bashRulesHonoringAllTools(config.permission);
36153
+ const permission = config.permission.bash !== void 0 || Object.keys(resolvedBashRules).length > 0 ? {
36154
+ ...config.permission,
36155
+ bash: resolvedBashRules
36156
+ } : config.permission;
36157
+ const allToolsFailClosedType = computeAllToolsFailClosedType(config.permission["*"]);
36158
+ const categoriesWithOwnEntries = /* @__PURE__ */ new Set();
36159
+ for (const [category, rules] of Object.entries(permission)) {
36160
+ if (category === "*") {
36161
+ 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.");
36162
+ continue;
36163
+ }
35276
36164
  const augmentToolName = toAugmentToolName(category);
35277
36165
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(augmentToolName) && augmentToolName === category) logger?.warn(`AugmentCode permissions: passing through unknown tool category '${category}' as toolName.`);
35278
36166
  if (augmentToolName === "launch-process") {
@@ -35297,19 +36185,50 @@ function convertRulesyncToAugmentEntries({ config, logger }) {
35297
36185
  toolName: augmentToolName,
35298
36186
  permission: { type: "deny" }
35299
36187
  });
36188
+ categoriesWithOwnEntries.add(category);
35300
36189
  continue;
35301
36190
  }
35302
36191
  const droppedPatterns = [];
35303
- for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") entries.push({
35304
- toolName: augmentToolName,
35305
- permission: { type: actionToAugmentType(action) }
35306
- });
35307
- else droppedPatterns.push(pattern);
36192
+ for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") {
36193
+ entries.push({
36194
+ toolName: augmentToolName,
36195
+ permission: { type: actionToAugmentType(action) }
36196
+ });
36197
+ categoriesWithOwnEntries.add(category);
36198
+ } else droppedPatterns.push(pattern);
35308
36199
  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.`);
35309
36200
  }
36201
+ entries.push(...synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType));
35310
36202
  return entries;
35311
36203
  }
35312
36204
  /**
36205
+ * The strictest action the all-tools `*` category imposes, for tools with no per-input matcher to
36206
+ * narrow it onto (see {@link synthesizeManagedToolFallbackEntries}). `deny` wins over `ask`,
36207
+ * and an all-tools `allow` never forces an entry — there is nothing to fail closed on.
36208
+ */
36209
+ function computeAllToolsFailClosedType(allToolsRules) {
36210
+ if (!allToolsRules) return void 0;
36211
+ const actions = Object.values(allToolsRules);
36212
+ if (actions.some((action) => action === "deny")) return "deny";
36213
+ if (actions.some((action) => action === "ask")) return "ask-user";
36214
+ }
36215
+ /**
36216
+ * Extend the same fail-closed treatment `bash` gets (via {@link bashRulesHonoringAllTools}) to the
36217
+ * other managed tools: one that produced no entries of its own above must not fall back to
36218
+ * AugmentCode's own default just because it has no per-input matcher to narrow the all-tools
36219
+ * restriction onto. A category can be stated yet still emit nothing (e.g. only non-`*` allow/ask
36220
+ * patterns, dropped with a warning), so this checks emitted entries rather than whether the
36221
+ * category was merely present in the source config. A tool whose own rules did emit entries is
36222
+ * left untouched here.
36223
+ */
36224
+ function synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType) {
36225
+ if (allToolsFailClosedType === void 0) return [];
36226
+ return Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).filter(([canonicalName]) => canonicalName !== "bash" && !categoriesWithOwnEntries.has(canonicalName)).map(([, augmentToolName]) => ({
36227
+ toolName: augmentToolName,
36228
+ permission: { type: allToolsFailClosedType }
36229
+ }));
36230
+ }
36231
+ /**
35313
36232
  * Sort AugmentCode tool-permission entries to make the `first-match-wins` semantics safe and predictable.
35314
36233
  *
35315
36234
  * Augment evaluates `toolPermissions` top-to-bottom and stops at the first match. To prevent a
@@ -35404,216 +36323,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
35404
36323
  }
35405
36324
  return { permission };
35406
36325
  }
35407
- /**
35408
- * Collect the canonical rules that govern shell commands, for the adapters
35409
- * whose tool models commands and nothing else.
35410
- *
35411
- * The `bash` category contributes every rule. The all-tools `*` category
35412
- * contributes its **restricting** rules — `deny` and `ask` — because a rule
35413
- * written there covers shell commands too, and dropping it inverts the
35414
- * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
35415
- * an adapter that reads only `bash` auto-approves the very command the file
35416
- * denies.
35417
- *
35418
- * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35419
- * need not be a command at all — `secrets/**` under `*` denies a path — and
35420
- * carrying it in the restricting direction only over-restricts, while carrying
35421
- * it in the permissive direction would grant something the author never said
35422
- * about commands. Both directions therefore fail closed.
35423
- */
35424
- function collectShellCommandRules(permission) {
35425
- const rules = [];
35426
- const foreignRestrictingCategories = [];
35427
- const ignoredAllToolsAllowPatterns = [];
35428
- for (const [category, categoryRules] of Object.entries(permission)) {
35429
- if (category === "bash") {
35430
- for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35431
- pattern,
35432
- action,
35433
- fromAllToolsCategory: false
35434
- });
35435
- continue;
35436
- }
35437
- if (category === "*") {
35438
- for (const [pattern, action] of Object.entries(categoryRules)) {
35439
- if (action === "allow") {
35440
- ignoredAllToolsAllowPatterns.push(pattern);
35441
- continue;
35442
- }
35443
- rules.push({
35444
- pattern,
35445
- action,
35446
- fromAllToolsCategory: true
35447
- });
35448
- }
35449
- continue;
35450
- }
35451
- if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35452
- }
35453
- return {
35454
- rules,
35455
- foreignRestrictingCategories,
35456
- ignoredAllToolsAllowPatterns
35457
- };
35458
- }
35459
- /**
35460
- * Build the test an adapter applies to an `allow` pattern before writing it:
35461
- * which restrictions it cannot write name some of the same commands? The
35462
- * answer is the list of those restrictions — empty when the `allow` may be
35463
- * written — so a caller can report both the allow rules it withheld and the
35464
- * restrictions that withheld nothing.
35465
- *
35466
- * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35467
- * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35468
- * asking whether any one command matches both. Width does not enter into it: an
35469
- * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35470
- * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35471
- * on every `git ... --force` command even though neither pattern covers the
35472
- * other's spelling. Comparing only identical spellings would let the most
35473
- * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35474
- *
35475
- * Identical spellings are still compared as strings first, as a shortcut past
35476
- * the walk for the commonest case.
35477
- *
35478
- * `normalizePattern` rewrites a pattern written in the tool's own language into
35479
- * the widest glob it could stand for, for a tool whose patterns are not globs.
35480
- * It reaches the `bash` rules and the `allow` rules, which is where such a
35481
- * pattern is written; an all-tools `*` pattern is canonical — it is read by
35482
- * every tool, so it is a glob already — and is compared as it stands. The
35483
- * rewrite must only ever widen what a pattern covers, so an inexact reading
35484
- * withholds an allow rather than writing one the config restricts — see
35485
- * `warpCommandPatternToGlob`.
35486
- */
35487
- function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35488
- const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35489
- pattern,
35490
- glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35491
- }));
35492
- return (allowPattern) => {
35493
- if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35494
- const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35495
- return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35496
- };
35497
- }
35498
- /**
35499
- * Which of the given all-tools `*` restrictions look like they may not name a
35500
- * command at all — the question a `deny` and an `ask` written there both raise.
35501
- *
35502
- * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35503
- * rules has nothing to withhold, and a pattern the author also wrote under
35504
- * `bash` is a command on their own word. Both are excluded, so what remains is
35505
- * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35506
- * is claimed as a command nowhere else — the shape `secrets/**` has.
35507
- *
35508
- * A `bash` restriction never belongs here: it names a command by construction,
35509
- * so overlapping no allow rule says nothing is wrong with it.
35510
- */
35511
- function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35512
- if (!rules.some(({ action }) => action === "allow")) return [];
35513
- const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35514
- return uniq(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35515
- }
35516
- /**
35517
- * Split shell-command rules into the allow and deny lists of a tool that models
35518
- * commands with those two tiers and nothing else.
35519
- *
35520
- * `ask` has no list of its own — such a tool already prompts for whatever it
35521
- * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35522
- * still has to *withhold* the `allow` rules it covers, though: the canonical
35523
- * order is `deny > ask > allow`, so auto-approving a command the file also asks
35524
- * about would answer the prompt the author wanted.
35525
- *
35526
- * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35527
- * from the all-tools `*` category. Warp's cannot: it matches commands with
35528
- * regular expressions rather than globs, and writing any denylist **replaces**
35529
- * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35530
- * the tool's own protection for a rule that matches no command. Where the deny
35531
- * cannot be written it withholds the allow rules it covers instead, which
35532
- * restricts in the same direction without touching the denylist.
35533
- *
35534
- * A `bash` deny withholds nothing: it names a command by construction, so the
35535
- * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35536
- * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35537
- * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35538
- * even where it is written: a pattern under `*` need not name a command —
35539
- * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35540
- * at all, and leaving an overlapping allow beside it would auto-approve the very
35541
- * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35542
- * command pattern is reported; failing open would not be.
35543
- *
35544
- * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35545
- * patterns are not globs.
35546
- */
35547
- function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35548
- const deny = [];
35549
- const unwrittenDenyPatterns = [];
35550
- const restrictions = [];
35551
- const writtenAllToolsDenyPatterns = [];
35552
- const allToolsAskPatterns = [];
35553
- for (const rule of rules) {
35554
- const { pattern, action, fromAllToolsCategory } = rule;
35555
- if (action === "allow") continue;
35556
- if (action !== "deny") {
35557
- restrictions.push(rule);
35558
- if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35559
- continue;
35560
- }
35561
- if (writesAllToolsDeny || !fromAllToolsCategory) {
35562
- deny.push(pattern);
35563
- if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35564
- } else unwrittenDenyPatterns.push(pattern);
35565
- if (fromAllToolsCategory) restrictions.push(rule);
35566
- }
35567
- const budget = createIntersectionBudget();
35568
- const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35569
- normalizePattern,
35570
- budget
35571
- });
35572
- const allow = [];
35573
- const shadowedAllowPatterns = [];
35574
- const withholdingPatterns = /* @__PURE__ */ new Set();
35575
- for (const { pattern, action } of rules) {
35576
- if (action !== "allow") continue;
35577
- const shadowing = shadowingRestrictions(pattern);
35578
- if (shadowing.length > 0) {
35579
- shadowedAllowPatterns.push(pattern);
35580
- for (const restriction of shadowing) withholdingPatterns.add(restriction);
35581
- continue;
35582
- }
35583
- allow.push(pattern);
35584
- }
35585
- return {
35586
- allow,
35587
- deny,
35588
- shadowedAllowPatterns,
35589
- unwrittenDenyPatterns,
35590
- unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35591
- rules,
35592
- allToolsPatterns: writtenAllToolsDenyPatterns,
35593
- withholdingPatterns
35594
- }),
35595
- unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35596
- rules,
35597
- allToolsPatterns: allToolsAskPatterns,
35598
- withholdingPatterns
35599
- }),
35600
- intersectionBudgetExhausted: budget.remaining === 0
35601
- };
35602
- }
35603
- /**
35604
- * Report, for one command-only tool, every canonical rule its two lists could
35605
- * not carry. Every command-only adapter shares this reporting, so a rule
35606
- * dropped in one is worded the same way in all.
35607
- */
35608
- function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35609
- 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.`);
35610
- 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.`);
35611
- 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.`);
35612
- 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.`);
35613
- 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.`);
35614
- 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.`);
35615
- 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.`);
35616
- }
35617
36326
  //#endregion
35618
36327
  //#region src/features/permissions/claudecode-permissions.ts
35619
36328
  /**
@@ -37375,7 +38084,7 @@ function mergeFilesystemCategoryRules({ categoryRules, logger }) {
37375
38084
  return merged;
37376
38085
  }
37377
38086
  function buildCodexBashRulesContent(config) {
37378
- const bashRules = config.permission.bash ?? {};
38087
+ const bashRules = bashRulesHonoringAllTools(config.permission);
37379
38088
  const entries = Object.entries(bashRules);
37380
38089
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
37381
38090
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
@@ -38066,7 +38775,7 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
38066
38775
  function convertRulesyncToCursorPermissions(config, logger) {
38067
38776
  const allow = [];
38068
38777
  const deny = [];
38069
- for (const [category, rules] of Object.entries(config.permission)) {
38778
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38070
38779
  const cursorType = toCursorType(category);
38071
38780
  for (const [pattern, action] of Object.entries(rules)) {
38072
38781
  const entry = buildCursorPermissionEntry(cursorType, toCursorPattern(category, pattern));
@@ -38885,7 +39594,7 @@ function convertRulesyncToDevinPermissions(config) {
38885
39594
  const allow = [];
38886
39595
  const ask = [];
38887
39596
  const deny = [];
38888
- for (const [category, rules] of Object.entries(config.permission)) {
39597
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38889
39598
  const scope = toDevinScope(category);
38890
39599
  for (const [pattern, action] of Object.entries(rules)) {
38891
39600
  const entry = buildDevinPermissionEntry(scope, pattern);
@@ -39173,6 +39882,19 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
39173
39882
  isDeletable() {
39174
39883
  return false;
39175
39884
  }
39885
+ /**
39886
+ * `permission.yaml` is Goose's file, not one rulesync owns: rulesync merges
39887
+ * into it when it exists but has no business bringing it into existence to
39888
+ * hold nothing. When no rule maps, the `user` block holds three empty lists,
39889
+ * which would otherwise be written as a fresh permission.yaml that says
39890
+ * nothing — an absent file and empty lists both mean "no user override, so
39891
+ * Goose decides on its own". An existing file is still rewritten as before,
39892
+ * so user content is never dropped — the skip only applies when there is no
39893
+ * file yet.
39894
+ */
39895
+ shouldSkipCreationWhenPayloadEmpty() {
39896
+ return true;
39897
+ }
39176
39898
  static getSettablePaths(_options) {
39177
39899
  return {
39178
39900
  relativeDirPath: GOOSE_GLOBAL_DIR,
@@ -39257,7 +39979,7 @@ function convertRulesyncToGoosePermissionConfig({ config, logger }) {
39257
39979
  never_allow: []
39258
39980
  };
39259
39981
  const assigned = /* @__PURE__ */ new Map();
39260
- const orderedEntries = Object.entries(config.permission).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
39982
+ const orderedEntries = Object.entries(honorAllToolsOnBash(config.permission)).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
39261
39983
  for (const [category, rules] of orderedEntries) {
39262
39984
  const toolName = RULESYNC_TO_GOOSE_TOOL_NAME[category] ?? category;
39263
39985
  for (const [pattern, action] of Object.entries(rules)) {
@@ -39589,7 +40311,7 @@ function unmanagedEntries(existingPermission, key) {
39589
40311
  */
39590
40312
  function buildGrokPermissionArrays(config, existingPermission, logger) {
39591
40313
  const ranked = /* @__PURE__ */ new Map();
39592
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
40314
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
39593
40315
  const entry = buildGrokEntry(category, pattern);
39594
40316
  if (entry === null) {
39595
40317
  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.`);
@@ -39678,6 +40400,8 @@ function deriveGrokPermissionMode(config) {
39678
40400
  function patternsByAction(category, action) {
39679
40401
  return Object.entries(category ?? {}).filter(([, value]) => value === action).map(([pattern]) => pattern);
39680
40402
  }
40403
+ /** The canonical category whose `deny` rules feed `security.website_blocklist`. */
40404
+ const WEBFETCH_PERMISSION_CATEGORY = "webfetch";
39681
40405
  function clonePermissionBlock(permission) {
39682
40406
  return Object.fromEntries(Object.entries(permission).map(([category, rules]) => [category, { ...rules }]));
39683
40407
  }
@@ -39690,18 +40414,39 @@ function ensureCategory(permission, category) {
39690
40414
  function removeEmptyCategories(permission) {
39691
40415
  for (const [category, rules] of Object.entries(permission)) if (Object.keys(rules).length === 0) delete permission[category];
39692
40416
  }
40417
+ /**
40418
+ * Make the native `command_allowlist` authoritative for the `bash` allow rules
40419
+ * it can speak for, and for those only. The allowlist is a list of shell-command
40420
+ * patterns, so it is generated from `bash` alone (see `fromRulesyncPermissions`);
40421
+ * an allow in any other category names something Hermes's allowlist cannot
40422
+ * carry, so its absence from the list says nothing about it and it is kept as
40423
+ * provenance wrote it. The same holds for a `bash` allow the generator withheld
40424
+ * because a stricter `*` or `bash` rule covers it: the allowlist never carried
40425
+ * it, so its absence is not a retraction and the rule is kept too.
40426
+ */
39693
40427
  function reconcileCommandAllowlist({ permission, commandAllowlist }) {
39694
- const nativeAllows = new Set(commandAllowlist);
39695
- const existingAllowCategories = /* @__PURE__ */ new Map();
39696
- for (const [category, rules] of Object.entries(permission)) for (const [pattern, action] of Object.entries(rules)) {
39697
- if (action !== "allow") continue;
39698
- existingAllowCategories.set(pattern, category);
39699
- if (!nativeAllows.has(pattern)) delete rules[pattern];
39700
- }
39701
- for (const pattern of nativeAllows) {
39702
- const existingCategory = existingAllowCategories.get(pattern);
39703
- if (existingCategory) ensureCategory(permission, existingCategory)[pattern] = "allow";
39704
- else ensureCategory(permission, "bash")[pattern] = "allow";
40428
+ const { rules: commandRules } = collectShellCommandRules(permission);
40429
+ const { shadowedAllowPatterns } = partitionCommandRules({
40430
+ rules: commandRules,
40431
+ writesAllToolsDeny: false
40432
+ });
40433
+ const withheld = new Set(shadowedAllowPatterns);
40434
+ const rules = ensureCategory(permission, SHELL_PERMISSION_CATEGORY);
40435
+ for (const [pattern, action] of Object.entries(rules)) if (action === "allow" && !withheld.has(pattern)) delete rules[pattern];
40436
+ for (const pattern of commandAllowlist) rules[pattern] = "allow";
40437
+ }
40438
+ /**
40439
+ * Report the restricting rules Hermes has no per-pattern primitive for: a
40440
+ * `deny` or `ask` in any category other than `bash`, `*`, and `webfetch`, and
40441
+ * an `ask` under `webfetch` — the blocklist carries a `webfetch` deny but has
40442
+ * no ask tier. (`bash` and `*` are reported by `warnAboutUnwrittenCommandRules`.)
40443
+ * Such rules survive only in the round-trip blob.
40444
+ */
40445
+ function warnAboutUnexpressedHermesRestrictions({ permissionBlock, foreignRestrictingCategories, logger }) {
40446
+ for (const category of foreignRestrictingCategories) {
40447
+ const isWebfetch = category === WEBFETCH_PERMISSION_CATEGORY;
40448
+ if (isWebfetch && patternsByAction(permissionBlock[category], "ask").length === 0) continue;
40449
+ 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.`);
39705
40450
  }
39706
40451
  }
39707
40452
  function reconcileNativeDenies({ permission, category, patterns }) {
@@ -39813,14 +40558,14 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39813
40558
  const approvals = isRecord$1(config.approvals) ? config.approvals : {};
39814
40559
  reconcileNativeDenies({
39815
40560
  permission,
39816
- category: "bash",
40561
+ category: SHELL_PERMISSION_CATEGORY,
39817
40562
  patterns: isStringArray$2(approvals.deny) ? approvals.deny : []
39818
40563
  });
39819
40564
  const security = isRecord$1(config.security) ? config.security : {};
39820
40565
  const websiteBlocklist = isRecord$1(security.website_blocklist) ? security.website_blocklist : {};
39821
40566
  reconcileNativeDenies({
39822
40567
  permission,
39823
- category: "webfetch",
40568
+ category: WEBFETCH_PERMISSION_CATEGORY,
39824
40569
  patterns: websiteBlocklist.enabled === true && isStringArray$2(websiteBlocklist.domains) ? websiteBlocklist.domains : []
39825
40570
  });
39826
40571
  removeEmptyCategories(permission);
@@ -39840,12 +40585,32 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39840
40585
  fileContent: JSON.stringify(imported, null, 2)
39841
40586
  });
39842
40587
  }
39843
- static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
40588
+ static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false, logger }) {
39844
40589
  const permissions = rulesyncPermissions.getJson();
39845
40590
  const permissionBlock = permissions.permission ?? {};
39846
- const commandAllowlist = Object.entries(permissionBlock).flatMap(([, patterns]) => patternsByAction(patterns, "allow"));
39847
- const bashDeny = patternsByAction(permissionBlock.bash, "deny");
39848
- const webfetchDeny = patternsByAction(permissionBlock.webfetch, "deny");
40591
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permissionBlock);
40592
+ const { allow: commandAllowlist, deny: bashDeny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
40593
+ rules,
40594
+ writesAllToolsDeny: false
40595
+ });
40596
+ warnAboutUnexpressedHermesRestrictions({
40597
+ permissionBlock,
40598
+ foreignRestrictingCategories,
40599
+ logger
40600
+ });
40601
+ warnAboutUnwrittenCommandRules({
40602
+ toolLabel: "Hermes Agent",
40603
+ surfaceLabel: "command_allowlist/approvals.deny",
40604
+ foreignRestrictingCategories: [],
40605
+ shadowedAllowPatterns,
40606
+ unwrittenDenyPatterns,
40607
+ unwrittenDenyReason: "approvals.deny is a hard denylist of shell commands, and a pattern written under '*' need not be a command at all.",
40608
+ unenforcedAllToolsAskPatterns,
40609
+ ignoredAllToolsAllowPatterns,
40610
+ intersectionBudgetExhausted,
40611
+ logger
40612
+ });
40613
+ const webfetchDeny = patternsByAction(permissionBlock[WEBFETCH_PERMISSION_CATEGORY], "deny");
39849
40614
  let config = {};
39850
40615
  if (commandAllowlist.length > 0) config.command_allowlist = commandAllowlist;
39851
40616
  if (bashDeny.length > 0) config.approvals = { deny: bashDeny };
@@ -40112,7 +40877,7 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
40112
40877
  */
40113
40878
  function convertRulesyncToJunieRules({ config, logger, existingRules, overrideSecretFile, overrideRuleDefaults }) {
40114
40879
  const ruleLists = {};
40115
- for (const [category, patterns] of Object.entries(config.permission)) {
40880
+ for (const [category, patterns] of Object.entries(honorAllToolsOnBash(config.permission))) {
40116
40881
  const group = CANONICAL_TO_JUNIE_GROUP[category];
40117
40882
  if (!group) {
40118
40883
  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.`);
@@ -40362,7 +41127,7 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
40362
41127
  const rulesyncJson = rulesyncPermissions.getJson();
40363
41128
  const kiloOverride = rulesyncJson.kilo;
40364
41129
  const incomingPermission = {
40365
- ...rulesyncJson.permission,
41130
+ ...honorAllToolsOnBash(rulesyncJson.permission),
40366
41131
  ...kiloOverride?.permission
40367
41132
  };
40368
41133
  const droppedDenyByKey = {};
@@ -40881,7 +41646,7 @@ function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
40881
41646
  allowedCommands: [],
40882
41647
  deniedCommands: []
40883
41648
  };
40884
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
41649
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
40885
41650
  if (action === "ask") {
40886
41651
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
40887
41652
  continue;
@@ -41165,7 +41930,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
41165
41930
  const rulesyncJson = rulesyncPermissions.getJson();
41166
41931
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
41167
41932
  const sharedPermission = {};
41168
- for (const [category, value] of Object.entries(rulesyncJson.permission ?? {})) sharedPermission[toOpencodePermissionKey(category)] = value;
41933
+ for (const [category, value] of Object.entries(honorAllToolsOnBash(rulesyncJson.permission ?? {}))) sharedPermission[toOpencodePermissionKey(category)] = value;
41169
41934
  const permission = {};
41170
41935
  for (const [category, value] of Object.entries({
41171
41936
  ...sharedPermission,
@@ -42294,11 +43059,19 @@ var RooPermissions = class extends ToolPermissions {
42294
43059
  const paths = this.getSettablePaths();
42295
43060
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
42296
43061
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
42297
- const rules = rulesyncPermissions.getJson().permission[COMMAND_CATEGORY];
43062
+ const permission = rulesyncPermissions.getJson().permission;
43063
+ const bashStated = permission[COMMAND_CATEGORY] !== void 0;
42298
43064
  const patch = {};
42299
- if (rules !== void 0) {
43065
+ if (bashStated) {
43066
+ const { bash } = resolveShellCommandLists({
43067
+ permission,
43068
+ writesAllToolsDeny: true,
43069
+ toolLabel: this.getToolLabel(),
43070
+ surfaceLabel: `${this.getAllowedCommandsKey()}/${this.getDeniedCommandsKey()}`,
43071
+ logger
43072
+ });
42300
43073
  const { allowed, denied } = buildVscodeCommandLists({
42301
- rules,
43074
+ rules: bash,
42302
43075
  toolLabel: this.getToolLabel(),
42303
43076
  logger
42304
43077
  });
@@ -42309,7 +43082,7 @@ var RooPermissions = class extends ToolPermissions {
42309
43082
  outputRoot,
42310
43083
  relativeDirPath: paths.relativeDirPath,
42311
43084
  relativeFilePath: paths.relativeFilePath,
42312
- ownsCommandKeys: rules !== void 0,
43085
+ ownsCommandKeys: bashStated,
42313
43086
  fileContent: applySharedConfigPatch({
42314
43087
  fileKey: sharedConfigFileKey(paths),
42315
43088
  feature: "permissions",
@@ -42689,7 +43462,7 @@ function convertRulesyncToRovodevToolPermissions({ config, logger }) {
42689
43462
  config,
42690
43463
  logger
42691
43464
  });
42692
- for (const [category, rules] of Object.entries(config.permission)) {
43465
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42693
43466
  if (category === CATCH_ALL_PATTERN$1) {
42694
43467
  const toolWideDefault = convertAllToolsRules({
42695
43468
  rules,
@@ -43699,7 +44472,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
43699
44472
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43700
44473
  const existingContent = await readFileContentOrNull(filePath) ?? "";
43701
44474
  const config = parseVibeConfig(existingContent);
43702
- const permission = rulesyncPermissions.getJson().permission;
44475
+ const permission = honorAllToolsOnBash(rulesyncPermissions.getJson().permission);
43703
44476
  const vibeOverride = rulesyncPermissions.getJson().vibe;
43704
44477
  const tools = toVibeToolsRecord(config.tools);
43705
44478
  const diskShellPatterns = new Map([VIBE_SHELL_CATEGORY, ...VIBE_SHELL_ALIAS_TOOL_NAMES].map((vibeToolName) => [vibeToolName, toStringArray(readVibeToolConfig({
@@ -45040,6 +45813,19 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
45040
45813
  isDeletable() {
45041
45814
  return false;
45042
45815
  }
45816
+ /**
45817
+ * `settings.toml` is Warp's file, not one rulesync owns: rulesync merges into
45818
+ * it when it exists but has no business bringing it into existence to hold
45819
+ * nothing. When no rule maps, both command lists are dropped and the payload
45820
+ * is a bare `[agents.profiles]` table, which would otherwise be written as a
45821
+ * fresh settings file that says nothing — an absent file and an empty table
45822
+ * both mean "Warp's own defaults". An existing file is still rewritten as
45823
+ * before, so user content is never dropped — the skip only applies when
45824
+ * there is no file yet.
45825
+ */
45826
+ shouldSkipCreationWhenPayloadEmpty() {
45827
+ return true;
45828
+ }
45043
45829
  static getSettablePaths(_options) {
45044
45830
  return {
45045
45831
  relativeDirPath: warpSettingsDir(),
@@ -45651,6 +46437,7 @@ function buildZedToolPermissions({ permission, logger }) {
45651
46437
  for (const [category, rules] of Object.entries(permission)) {
45652
46438
  if (category === "*") {
45653
46439
  for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
46440
+ else if (permission.bash?.[pattern] === "deny" || permission.bash?.[pattern] === "ask") continue;
45654
46441
  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.`);
45655
46442
  continue;
45656
46443
  }
@@ -45805,7 +46592,7 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
45805
46592
  const toolPermissions = asRecord(agent.tool_permissions);
45806
46593
  const existingTools = asRecord(toolPermissions.tools);
45807
46594
  const { managedDefault, managedTools, excludedCategories, inertMcpCategories } = buildZedToolPermissions({
45808
- permission: config.permission,
46595
+ permission: honorAllToolsOnBash(config.permission),
45809
46596
  logger
45810
46597
  });
45811
46598
  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\`.`);
@@ -46314,6 +47101,24 @@ var PermissionsProcessor = class extends FeatureProcessor {
46314
47101
  }
46315
47102
  };
46316
47103
  //#endregion
47104
+ //#region src/constants/codebuddy-paths.ts
47105
+ /**
47106
+ * CodeBuddy Code configuration-layout conventions.
47107
+ *
47108
+ * CodeBuddy Code (`@tencent-ai/codebuddy-code`) is Tencent Cloud's terminal
47109
+ * coding agent. Its configuration surface mirrors Claude Code closely: a
47110
+ * root memory file plus a `.codebuddy/` tree.
47111
+ *
47112
+ * @see https://www.codebuddy.ai/docs/cli/memory
47113
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
47114
+ */
47115
+ /** Root directory for CodeBuddy Code configuration, relative to the scope root. */
47116
+ const CODEBUDDY_DIR = ".codebuddy";
47117
+ const CODEBUDDY_RULE_FILE_NAME = "CODEBUDDY.md";
47118
+ const CODEBUDDY_LOCAL_RULE_FILE_NAME = "CODEBUDDY.local.md";
47119
+ /** Modular rules directory name under `.codebuddy/`. */
47120
+ const CODEBUDDY_RULES_DIR_NAME = "rules";
47121
+ //#endregion
46317
47122
  //#region src/features/skills/simulated-skill.ts
46318
47123
  const SimulatedSkillFrontmatterSchema = z.looseObject({
46319
47124
  name: z.string(),
@@ -46852,7 +47657,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46852
47657
  * This only deletes directories that are no longer in the rulesync source, not directories that will be overwritten.
46853
47658
  */
46854
47659
  async removeOrphanAiDirs(existingDirs, generatedDirs) {
46855
- const generatedPaths = new Set(generatedDirs.map((d) => d.getDirPath()));
47660
+ const generatedPaths = new Set(generatedDirs.map((d) => caseFoldIdentity(d.getDirPath())));
46856
47661
  const orphanPaths = /* @__PURE__ */ new Set();
46857
47662
  const quotedOutputRoot = quoteForLog(this.outputRoot);
46858
47663
  for (const aiDir of existingDirs) {
@@ -46877,7 +47682,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46877
47682
  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`);
46878
47683
  continue;
46879
47684
  }
46880
- if (!generatedPaths.has(dirPath)) orphanPaths.add(dirPath);
47685
+ if (!generatedPaths.has(caseFoldIdentity(dirPath))) orphanPaths.add(dirPath);
46881
47686
  }
46882
47687
  return await this.deleteOrphanPaths({
46883
47688
  paths: orphanPaths,
@@ -46976,7 +47781,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46976
47781
  const mainFile = aiDir.getMainFile();
46977
47782
  if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
46978
47783
  for (const file of aiDir.getOtherFiles()) generatedNames.add(toPosixPath(file.relativeFilePathToDirPath));
46979
- const generatedNamesFolded = new Set([...generatedNames].map((name) => name.toLowerCase()));
47784
+ const generatedNamesFolded = new Set([...generatedNames].map((name) => caseFoldIdentity(name)));
46980
47785
  let existingNames;
46981
47786
  try {
46982
47787
  existingNames = await listFilePathsRecursively(dirPath, {
@@ -46991,7 +47796,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46991
47796
  const posixName = toPosixPath(existingName);
46992
47797
  if (generatedNames.has(posixName)) continue;
46993
47798
  const filePath = join(dirPath, existingName);
46994
- if (generatedNamesFolded.has(posixName.toLowerCase())) {
47799
+ if (generatedNamesFolded.has(caseFoldIdentity(posixName))) {
46995
47800
  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`);
46996
47801
  continue;
46997
47802
  }
@@ -47047,7 +47852,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47047
47852
  generatedPaths.add(flatFilePath);
47048
47853
  for (const file of generatedDir.getOtherFiles()) generatedPaths.add(join(generatedDirPath, file.relativeFilePathToDirPath));
47049
47854
  }
47050
- const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => generatedPath.toLowerCase()));
47855
+ const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => caseFoldIdentity(generatedPath)));
47051
47856
  const orphanPaths = /* @__PURE__ */ new Set();
47052
47857
  const quotedOutputRoot = quoteForLog(this.outputRoot);
47053
47858
  for (const aiDir of existingFlatFiles) {
@@ -47077,7 +47882,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47077
47882
  continue;
47078
47883
  }
47079
47884
  if (generatedPaths.has(filePath)) continue;
47080
- if (generatedPathsFolded.has(filePath.toLowerCase())) {
47885
+ if (generatedPathsFolded.has(caseFoldIdentity(filePath))) {
47081
47886
  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`);
47082
47887
  continue;
47083
47888
  }
@@ -48909,6 +49714,191 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
48909
49714
  }
48910
49715
  };
48911
49716
  //#endregion
49717
+ //#region src/features/skills/crush-skill.ts
49718
+ const CrushSkillFrontmatterSchema = z.looseObject({
49719
+ name: z.string(),
49720
+ description: z.string(),
49721
+ "user-invocable": z.optional(z.boolean()),
49722
+ "disable-model-invocation": z.optional(z.boolean()),
49723
+ license: z.optional(z.string()),
49724
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
49725
+ metadata: z.optional(z.looseObject({}))
49726
+ });
49727
+ /**
49728
+ * Represents a Crush Agent Skill directory.
49729
+ *
49730
+ * Crush auto-discovers Agent Skills (`SKILL.md` per directory) from
49731
+ * `.crush/skills/` at project scope and `~/.config/crush/skills/` (or
49732
+ * `$CRUSH_SKILLS_DIR`) at global scope. Unless `$CRUSH_SKILLS_DIR` is set,
49733
+ * Crush also scans several shared directories it does not own (globally
49734
+ * `~/.config/agents/skills/`, `~/.agents/skills/`, `~/.claude/skills/`;
49735
+ * per-project `.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, also
49736
+ * checked at a git worktree's common root); this class writes only to the
49737
+ * Crush-specific path above, leaving those shared roots to their own targets.
49738
+ *
49739
+ * Crush's `UserInvocable` field is a non-pointer Go `bool`, so an omitted
49740
+ * `user-invocable` (at both the root and the `crush:` section) resolves to
49741
+ * `false`: the skill stays reachable by the model but is hidden from Crush's
49742
+ * command palette. See `FromSkillCatalog` in `internal/commands/commands.go`.
49743
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
49744
+ */
49745
+ var CrushSkill = class CrushSkill extends ToolSkill {
49746
+ constructor({ outputRoot = process.cwd(), relativeDirPath = CRUSH_SKILLS_PROJECT_DIR, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
49747
+ super({
49748
+ outputRoot,
49749
+ relativeDirPath,
49750
+ dirName,
49751
+ mainFile: {
49752
+ name: SKILL_FILE_NAME,
49753
+ body,
49754
+ frontmatter: { ...frontmatter }
49755
+ },
49756
+ otherFiles,
49757
+ global
49758
+ });
49759
+ if (validate) {
49760
+ const result = this.validate();
49761
+ if (!result.success) throw result.error;
49762
+ }
49763
+ }
49764
+ static getSettablePaths({ global = false } = {}) {
49765
+ return { relativeDirPath: global ? CRUSH_SKILLS_GLOBAL_DIR : CRUSH_SKILLS_PROJECT_DIR };
49766
+ }
49767
+ getFrontmatter() {
49768
+ return CrushSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
49769
+ }
49770
+ getBody() {
49771
+ return this.mainFile?.body ?? "";
49772
+ }
49773
+ validate() {
49774
+ if (!this.mainFile) return {
49775
+ success: false,
49776
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
49777
+ };
49778
+ const result = CrushSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
49779
+ if (!result.success) return {
49780
+ success: false,
49781
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
49782
+ };
49783
+ return {
49784
+ success: true,
49785
+ error: null
49786
+ };
49787
+ }
49788
+ toRulesyncSkill() {
49789
+ const frontmatter = this.getFrontmatter();
49790
+ const crushSection = {
49791
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
49792
+ ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
49793
+ ...frontmatter.license !== void 0 && { license: frontmatter.license },
49794
+ ...frontmatter.compatibility !== void 0 && { compatibility: frontmatter.compatibility },
49795
+ ...frontmatter.metadata !== void 0 && { metadata: frontmatter.metadata }
49796
+ };
49797
+ const rulesyncFrontmatter = {
49798
+ name: frontmatter.name,
49799
+ description: frontmatter.description,
49800
+ targets: ["*"],
49801
+ ...Object.keys(crushSection).length > 0 && { crush: crushSection }
49802
+ };
49803
+ return new RulesyncSkill({
49804
+ outputRoot: this.outputRoot,
49805
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
49806
+ dirName: this.getDirName(),
49807
+ frontmatter: rulesyncFrontmatter,
49808
+ body: this.getBody(),
49809
+ otherFiles: this.getOtherFiles(),
49810
+ validate: true,
49811
+ global: this.global
49812
+ });
49813
+ }
49814
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49815
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49816
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49817
+ const crushSection = rulesyncFrontmatter.crush;
49818
+ const resolvedUserInvocable = resolveUserInvocable({
49819
+ rootFrontmatter: rulesyncFrontmatter,
49820
+ section: crushSection
49821
+ });
49822
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
49823
+ rootFrontmatter: rulesyncFrontmatter,
49824
+ section: crushSection
49825
+ });
49826
+ const license = resolveLicense({
49827
+ rootFrontmatter: rulesyncFrontmatter,
49828
+ section: crushSection
49829
+ });
49830
+ const compatibility = resolveCompatibility({
49831
+ rootFrontmatter: rulesyncFrontmatter,
49832
+ section: crushSection
49833
+ });
49834
+ const metadata = resolveMetadata({
49835
+ rootFrontmatter: rulesyncFrontmatter,
49836
+ section: crushSection
49837
+ });
49838
+ const compatibilityString = compatibility === void 0 ? void 0 : toCompatibilityString(compatibility);
49839
+ const crushFrontmatter = {
49840
+ name: rulesyncFrontmatter.name,
49841
+ description: rulesyncFrontmatter.description,
49842
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
49843
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
49844
+ ...license !== void 0 && { license },
49845
+ ...compatibilityString !== void 0 && compatibilityString.length > 0 && { compatibility: compatibilityString },
49846
+ ...metadata !== void 0 && { metadata: toStringMetadata(metadata) }
49847
+ };
49848
+ return new CrushSkill({
49849
+ outputRoot,
49850
+ relativeDirPath: settablePaths.relativeDirPath,
49851
+ dirName: rulesyncSkill.getDirName(),
49852
+ frontmatter: crushFrontmatter,
49853
+ body: rulesyncSkill.getBody(),
49854
+ otherFiles: rulesyncSkill.getOtherFiles(),
49855
+ validate,
49856
+ global
49857
+ });
49858
+ }
49859
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
49860
+ const targets = rulesyncSkill.getFrontmatter().targets;
49861
+ return targets.includes("*") || targets.includes("crush");
49862
+ }
49863
+ static async fromDir(params) {
49864
+ const loaded = await this.loadSkillDirContent({
49865
+ ...params,
49866
+ getSettablePaths: CrushSkill.getSettablePaths
49867
+ });
49868
+ const result = CrushSkillFrontmatterSchema.safeParse(loaded.frontmatter);
49869
+ if (!result.success) {
49870
+ const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
49871
+ throw new Error(`Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
49872
+ }
49873
+ return new CrushSkill({
49874
+ outputRoot: loaded.outputRoot,
49875
+ relativeDirPath: loaded.relativeDirPath,
49876
+ dirName: loaded.dirName,
49877
+ frontmatter: result.data,
49878
+ body: loaded.body,
49879
+ otherFiles: loaded.otherFiles,
49880
+ validate: true,
49881
+ global: loaded.global
49882
+ });
49883
+ }
49884
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
49885
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49886
+ return new CrushSkill({
49887
+ outputRoot,
49888
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
49889
+ dirName,
49890
+ frontmatter: {
49891
+ name: "",
49892
+ description: ""
49893
+ },
49894
+ body: "",
49895
+ otherFiles: [],
49896
+ validate: false,
49897
+ global
49898
+ });
49899
+ }
49900
+ };
49901
+ //#endregion
48912
49902
  //#region src/features/skills/cursor-skill.ts
48913
49903
  const CursorSkillFrontmatterSchema = z.looseObject({
48914
49904
  name: z.string(),
@@ -49073,9 +50063,9 @@ const DeepagentsSkillFrontmatterSchema = z.looseObject({
49073
50063
  name: z.string(),
49074
50064
  description: z.string(),
49075
50065
  "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
49076
- license: z.optional(z.string()),
49077
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
49078
- metadata: z.optional(z.looseObject({}))
50066
+ license: z.optional(z.unknown()),
50067
+ compatibility: z.optional(z.unknown()),
50068
+ metadata: z.optional(z.unknown())
49079
50069
  });
49080
50070
  var DeepagentsSkill = class DeepagentsSkill extends ToolSkill {
49081
50071
  constructor({ outputRoot = process.cwd(), relativeDirPath = DEEPAGENTS_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -50133,9 +51123,9 @@ var JunieSkill = class JunieSkill extends ToolSkill {
50133
51123
  const KiloSkillFrontmatterSchema = z.looseObject({
50134
51124
  name: z.string(),
50135
51125
  description: z.string(),
50136
- license: z.optional(z.string()),
50137
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
50138
- metadata: z.optional(z.looseObject({})),
51126
+ license: z.optional(z.unknown()),
51127
+ compatibility: z.optional(z.unknown()),
51128
+ metadata: z.optional(z.unknown()),
50139
51129
  "allowed-tools": z.optional(z.array(z.string()))
50140
51130
  });
50141
51131
  var KiloSkill = class KiloSkill extends ToolSkill {
@@ -50572,10 +51562,11 @@ var KiroSkill = class KiroSkill extends ToolSkill {
50572
51562
  rootFrontmatter: rulesyncFrontmatter,
50573
51563
  section: kiroSection
50574
51564
  });
51565
+ const { name: _sectionName, description: _sectionDescription, ...section } = kiroSection ?? {};
50575
51566
  const kiroFrontmatter = {
50576
- ...kiroSection,
50577
51567
  name: rulesyncFrontmatter.name,
50578
51568
  description: rulesyncFrontmatter.description,
51569
+ ...section,
50579
51570
  ...license !== void 0 && { license },
50580
51571
  ...compatibility !== void 0 && { compatibility },
50581
51572
  ...metadata !== void 0 && { metadata }
@@ -50807,9 +51798,9 @@ var MusecodeSkill = class MusecodeSkill extends ToolSkill {
50807
51798
  const OpenCodeSkillFrontmatterSchema = z.looseObject({
50808
51799
  name: z.string(),
50809
51800
  description: z.string(),
50810
- license: z.optional(z.string()),
50811
- compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
50812
- metadata: z.optional(z.looseObject({})),
51801
+ license: z.optional(z.unknown()),
51802
+ compatibility: z.optional(z.unknown()),
51803
+ metadata: z.optional(z.unknown()),
50813
51804
  "allowed-tools": z.optional(z.array(z.string()))
50814
51805
  });
50815
51806
  var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
@@ -52723,6 +53714,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
52723
53714
  supportsGlobal: true
52724
53715
  }
52725
53716
  }],
53717
+ ["crush", {
53718
+ class: CrushSkill,
53719
+ meta: {
53720
+ supportsProject: true,
53721
+ supportsSimulated: false,
53722
+ supportsGlobal: true
53723
+ }
53724
+ }],
52726
53725
  ["cursor", {
52727
53726
  class: CursorSkill,
52728
53727
  meta: {
@@ -60466,6 +61465,238 @@ var ClineRule = class ClineRule extends ToolRule {
60466
61465
  }
60467
61466
  };
60468
61467
  //#endregion
61468
+ //#region src/features/rules/codebuddy-rule.ts
61469
+ /**
61470
+ * Frontmatter schema for CodeBuddy Code modular rules.
61471
+ * @see https://www.codebuddy.ai/docs/cli/memory
61472
+ */
61473
+ const CodebuddyRuleFrontmatterSchema = z.object({
61474
+ description: z.optional(z.string()),
61475
+ paths: z.optional(z.array(z.string())),
61476
+ alwaysApply: z.optional(z.boolean())
61477
+ });
61478
+ /**
61479
+ * A universal glob (matching everything) is redundant on an Always Apply
61480
+ * rule and, paired with `alwaysApply: true`, is the same semantic conflict
61481
+ * `CursorRule.resolveCursorGlobs` avoids for Cursor: `alwaysApply` already
61482
+ * applies the rule everywhere, so also emitting an explicit
61483
+ * `paths: ["**\/*"]` is at best redundant and, on a subsequent
61484
+ * import/generate round-trip, misleadingly implies the rule is scoped by
61485
+ * path rather than always-on.
61486
+ */
61487
+ const UNIVERSAL_PATHS = /* @__PURE__ */ new Set(["**/*", "*"]);
61488
+ /**
61489
+ * Rule generator for CodeBuddy Code, Tencent Cloud's terminal coding agent
61490
+ * (`@tencent-ai/codebuddy-code`). Its configuration surface mirrors Claude
61491
+ * Code closely.
61492
+ *
61493
+ * Rules format:
61494
+ * - {project}/CODEBUDDY.md (root: true), also read from {project}/.codebuddy/CODEBUDDY.md
61495
+ * - {project}/.codebuddy/rules/*.md (root: false, with optional
61496
+ * `description` / `paths` / `alwaysApply` frontmatter)
61497
+ * - Global: ~/.codebuddy/CODEBUDDY.md and ~/.codebuddy/rules/*.md
61498
+ *
61499
+ * @see https://www.codebuddy.ai/docs/cli/memory
61500
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
61501
+ */
61502
+ var CodebuddyRule = class CodebuddyRule extends ToolRule {
61503
+ frontmatter;
61504
+ body;
61505
+ static getSettablePaths({ global, excludeToolDir } = {}) {
61506
+ if (global) return {
61507
+ root: {
61508
+ relativeDirPath: buildToolPath(CODEBUDDY_DIR, ".", excludeToolDir),
61509
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61510
+ },
61511
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61512
+ };
61513
+ return {
61514
+ root: {
61515
+ relativeDirPath: ".",
61516
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61517
+ },
61518
+ alternativeRoots: [{
61519
+ relativeDirPath: CODEBUDDY_DIR,
61520
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61521
+ }],
61522
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61523
+ };
61524
+ }
61525
+ constructor({ frontmatter, body, ...rest }) {
61526
+ if (rest.validate) {
61527
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61528
+ if (!result.success) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
61529
+ }
61530
+ super({
61531
+ ...rest,
61532
+ fileContent: rest.root ? body : CodebuddyRule.generateFileContent(body, frontmatter)
61533
+ });
61534
+ this.frontmatter = frontmatter;
61535
+ this.body = body;
61536
+ }
61537
+ static generateFileContent(body, frontmatter) {
61538
+ if (frontmatter.description === void 0 && frontmatter.paths === void 0 && frontmatter.alwaysApply === void 0) return body;
61539
+ return stringifyFrontmatter(body, {
61540
+ description: frontmatter.description,
61541
+ alwaysApply: frontmatter.alwaysApply,
61542
+ paths: frontmatter.paths
61543
+ });
61544
+ }
61545
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false, relativeDirPath: overrideDirPath }) {
61546
+ const paths = this.getSettablePaths({ global });
61547
+ if (relativeFilePath === paths.root.relativeFilePath) {
61548
+ const rootDirPath = overrideDirPath ?? paths.root.relativeDirPath;
61549
+ const fileContent = await readFileContent(join(outputRoot, rootDirPath, paths.root.relativeFilePath));
61550
+ return new CodebuddyRule({
61551
+ outputRoot,
61552
+ relativeDirPath: rootDirPath,
61553
+ relativeFilePath: paths.root.relativeFilePath,
61554
+ frontmatter: {},
61555
+ body: fileContent.trim(),
61556
+ validate,
61557
+ root: true
61558
+ });
61559
+ }
61560
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${relativeFilePath}`);
61561
+ const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath);
61562
+ const filePath = join(outputRoot, relativePath);
61563
+ const { frontmatter, body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
61564
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61565
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
61566
+ return new CodebuddyRule({
61567
+ outputRoot,
61568
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61569
+ relativeFilePath,
61570
+ frontmatter: result.data,
61571
+ body: content.trim(),
61572
+ validate,
61573
+ root: false
61574
+ });
61575
+ }
61576
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61577
+ const isRoot = relativeFilePath === this.getSettablePaths({ global }).root.relativeFilePath;
61578
+ return new CodebuddyRule({
61579
+ outputRoot,
61580
+ relativeDirPath,
61581
+ relativeFilePath,
61582
+ frontmatter: {},
61583
+ body: "",
61584
+ validate: false,
61585
+ root: isRoot
61586
+ });
61587
+ }
61588
+ static resolveCodebuddyPaths({ paths, alwaysApply }) {
61589
+ if (!paths || paths.length === 0) return;
61590
+ if (alwaysApply && paths.every((path) => UNIVERSAL_PATHS.has(path.trim()))) return;
61591
+ return paths;
61592
+ }
61593
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
61594
+ const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
61595
+ const root = rulesyncFrontmatter.root ?? false;
61596
+ const paths = this.getSettablePaths({ global });
61597
+ const body = rulesyncRule.getBody();
61598
+ if (root) return new CodebuddyRule({
61599
+ outputRoot,
61600
+ frontmatter: {},
61601
+ body,
61602
+ relativeDirPath: paths.root.relativeDirPath,
61603
+ relativeFilePath: paths.root.relativeFilePath,
61604
+ validate,
61605
+ root
61606
+ });
61607
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${rulesyncRule.getRelativeFilePath()}`);
61608
+ const codebuddyPaths = rulesyncFrontmatter.codebuddy?.paths;
61609
+ const globs = rulesyncFrontmatter.globs;
61610
+ const alwaysApply = rulesyncFrontmatter.codebuddy?.alwaysApply;
61611
+ const pathsValue = CodebuddyRule.resolveCodebuddyPaths({
61612
+ paths: codebuddyPaths ?? (globs?.length ? globs : void 0),
61613
+ alwaysApply: alwaysApply === true
61614
+ });
61615
+ const codebuddyFrontmatter = {
61616
+ description: rulesyncFrontmatter.codebuddy?.description ?? rulesyncFrontmatter.description,
61617
+ paths: pathsValue,
61618
+ alwaysApply
61619
+ };
61620
+ return new CodebuddyRule({
61621
+ outputRoot,
61622
+ frontmatter: codebuddyFrontmatter,
61623
+ body,
61624
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61625
+ relativeFilePath: rulesyncRule.getRelativeFilePath(),
61626
+ validate,
61627
+ root
61628
+ });
61629
+ }
61630
+ toRulesyncRule() {
61631
+ const targets = ["*"];
61632
+ if (this.isRoot()) {
61633
+ const rulesyncFrontmatter = {
61634
+ targets,
61635
+ root: true,
61636
+ description: this.description,
61637
+ globs: ["**/*"]
61638
+ };
61639
+ return new RulesyncRule({
61640
+ outputRoot: this.getOutputRoot(),
61641
+ frontmatter: rulesyncFrontmatter,
61642
+ body: this.body,
61643
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61644
+ relativeFilePath: this.getRelativeFilePath(),
61645
+ validate: true
61646
+ });
61647
+ }
61648
+ const isAlways = this.frontmatter.alwaysApply === true;
61649
+ const sourcePaths = this.frontmatter.paths ?? [];
61650
+ const globs = sourcePaths.length === 0 && isAlways ? ["**/*"] : sourcePaths;
61651
+ const rulesyncFrontmatter = {
61652
+ targets,
61653
+ root: false,
61654
+ description: this.frontmatter.description,
61655
+ globs,
61656
+ ...(this.frontmatter.paths !== void 0 || this.frontmatter.alwaysApply !== void 0 || this.frontmatter.description !== void 0) && { codebuddy: {
61657
+ paths: this.frontmatter.paths,
61658
+ alwaysApply: this.frontmatter.alwaysApply,
61659
+ description: this.frontmatter.description
61660
+ } }
61661
+ };
61662
+ return new RulesyncRule({
61663
+ outputRoot: this.getOutputRoot(),
61664
+ frontmatter: rulesyncFrontmatter,
61665
+ body: this.body,
61666
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61667
+ relativeFilePath: this.getRelativeFilePath(),
61668
+ validate: true
61669
+ });
61670
+ }
61671
+ validate() {
61672
+ if (!this.frontmatter) return {
61673
+ success: true,
61674
+ error: null
61675
+ };
61676
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(this.frontmatter);
61677
+ if (result.success) return {
61678
+ success: true,
61679
+ error: null
61680
+ };
61681
+ else return {
61682
+ success: false,
61683
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
61684
+ };
61685
+ }
61686
+ getFrontmatter() {
61687
+ return this.frontmatter;
61688
+ }
61689
+ getBody() {
61690
+ return this.body;
61691
+ }
61692
+ static isTargetedByRulesyncRule(rulesyncRule) {
61693
+ return this.isTargetedByRulesyncRuleDefault({
61694
+ rulesyncRule,
61695
+ toolTarget: "codebuddy"
61696
+ });
61697
+ }
61698
+ };
61699
+ //#endregion
60469
61700
  //#region src/features/rules/codexcli-rule.ts
60470
61701
  var CodexcliRule = class CodexcliRule extends ToolRule {
60471
61702
  constructor({ fileContent, root, ...rest }) {
@@ -60762,6 +61993,79 @@ var CopilotcliRule = class CopilotcliRule extends CopilotRule {
60762
61993
  }
60763
61994
  };
60764
61995
  //#endregion
61996
+ //#region src/features/rules/crush-rule.ts
61997
+ var CrushRule = class CrushRule extends ToolRule {
61998
+ constructor({ fileContent, root, ...rest }) {
61999
+ super({
62000
+ ...rest,
62001
+ fileContent,
62002
+ root: root ?? false
62003
+ });
62004
+ }
62005
+ static getSettablePaths({ global = false } = {}) {
62006
+ if (global) return { root: {
62007
+ relativeDirPath: CRUSH_GLOBAL_DIR,
62008
+ relativeFilePath: CRUSH_RULE_FILE_NAME
62009
+ } };
62010
+ return { root: {
62011
+ relativeDirPath: ".",
62012
+ relativeFilePath: CRUSH_RULE_FILE_NAME
62013
+ } };
62014
+ }
62015
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
62016
+ const { root } = this.getSettablePaths({ global });
62017
+ const relativePath = join(root.relativeDirPath, root.relativeFilePath);
62018
+ const fileContent = await readFileContent(join(outputRoot, relativePath));
62019
+ return new CrushRule({
62020
+ outputRoot,
62021
+ relativeDirPath: root.relativeDirPath,
62022
+ relativeFilePath: root.relativeFilePath,
62023
+ fileContent,
62024
+ validate,
62025
+ root: true
62026
+ });
62027
+ }
62028
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
62029
+ const { root } = this.getSettablePaths({ global });
62030
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
62031
+ return new CrushRule({
62032
+ outputRoot,
62033
+ relativeDirPath: root.relativeDirPath,
62034
+ relativeFilePath: root.relativeFilePath,
62035
+ fileContent: rulesyncRule.getBody(),
62036
+ validate,
62037
+ root: isRoot
62038
+ });
62039
+ }
62040
+ toRulesyncRule() {
62041
+ return this.toRulesyncRuleDefault();
62042
+ }
62043
+ validate() {
62044
+ return {
62045
+ success: true,
62046
+ error: null
62047
+ };
62048
+ }
62049
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
62050
+ const { root } = this.getSettablePaths({ global });
62051
+ const isRoot = relativeFilePath === root.relativeFilePath && relativeDirPath === root.relativeDirPath;
62052
+ return new CrushRule({
62053
+ outputRoot,
62054
+ relativeDirPath,
62055
+ relativeFilePath,
62056
+ fileContent: "",
62057
+ validate: false,
62058
+ root: isRoot
62059
+ });
62060
+ }
62061
+ static isTargetedByRulesyncRule(rulesyncRule) {
62062
+ return this.isTargetedByRulesyncRuleDefault({
62063
+ rulesyncRule,
62064
+ toolTarget: "crush"
62065
+ });
62066
+ }
62067
+ };
62068
+ //#endregion
60765
62069
  //#region src/features/rules/cursor-rule.ts
60766
62070
  const CursorRuleFrontmatterSchema = z.object({
60767
62071
  description: z.optional(z.string()),
@@ -61369,13 +62673,34 @@ var DevinRule = class DevinRule extends ToolRule {
61369
62673
  };
61370
62674
  //#endregion
61371
62675
  //#region src/features/rules/factorydroid-rule.ts
62676
+ /**
62677
+ * Rule generator for Factory Droid.
62678
+ *
62679
+ * Factory Droid loads the root `AGENTS.md` (project) / `~/.factory/AGENTS.md`
62680
+ * (global) as coding guidelines, plus non-root rules referenced from it via
62681
+ * `.factory/rules/*.md`.
62682
+ *
62683
+ * Factory Droid also loads `DESIGN.md` (project only) as a second,
62684
+ * independent instruction surface: "Always-on design-system, UX, visual, and
62685
+ * interaction guidance", loaded separately from `AGENTS.md`'s coding
62686
+ * guidelines. Rulesync emits it from any non-root rule that opts in via a
62687
+ * `factorydroid.channel: design` frontmatter block — those rule bodies are
62688
+ * routed to `DESIGN.md` instead of `AGENTS.md`/`.factory/rules/*.md`, and
62689
+ * multiple opted-in rules concatenate in source order. Factory's docs describe
62690
+ * `DESIGN.md` at the repository root and in nested subdirectories, like
62691
+ * `AGENTS.md`, but document no personal/global home-directory equivalent, so
62692
+ * this channel is project scope only.
62693
+ * @see https://docs.factory.ai/cli/configuration/agents-md
62694
+ */
61372
62695
  var FactorydroidRule = class FactorydroidRule extends ToolRule {
61373
- constructor({ fileContent, root, ...rest }) {
62696
+ design;
62697
+ constructor({ fileContent, root, design = false, ...rest }) {
61374
62698
  super({
61375
62699
  ...rest,
61376
62700
  fileContent,
61377
62701
  root: root ?? false
61378
62702
  });
62703
+ this.design = design;
61379
62704
  }
61380
62705
  static getSettablePaths({ global, excludeToolDir } = {}) {
61381
62706
  if (global) return { root: {
@@ -61387,11 +62712,47 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61387
62712
  relativeDirPath: ".",
61388
62713
  relativeFilePath: FACTORYDROID_RULE_FILE_NAME
61389
62714
  },
61390
- nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) }
62715
+ nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) },
62716
+ design: {
62717
+ relativeDirPath: ".",
62718
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME
62719
+ }
61391
62720
  };
61392
62721
  }
61393
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
62722
+ /**
62723
+ * Extra fixed files this tool manages beyond the root/non-root rules. The
62724
+ * RulesProcessor enumerates these for import and deletion so a stale
62725
+ * `DESIGN.md` is cleaned up once no rule opts in anymore. Empty in global
62726
+ * mode: `DESIGN.md` has no documented home-directory equivalent.
62727
+ */
62728
+ static getExtraFixedFiles({ global = false } = {}) {
62729
+ if (global) return [];
62730
+ return [this.getSettablePaths({ global }).design];
62731
+ }
62732
+ /**
62733
+ * Factory Droid loads `DESIGN.md` itself, so listing it in the root rule's
62734
+ * TOON reference section would double-load the content (and misrepresent it
62735
+ * as a rule the model must remember to open).
62736
+ */
62737
+ isExcludedFromRootReferences() {
62738
+ return this.design;
62739
+ }
62740
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
61394
62741
  const paths = this.getSettablePaths({ global });
62742
+ const design = !global ? paths.design : void 0;
62743
+ if (design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath) {
62744
+ const relativePath = join(design.relativeDirPath, design.relativeFilePath);
62745
+ const fileContent = await readFileContent(join(outputRoot, relativePath));
62746
+ return new FactorydroidRule({
62747
+ outputRoot,
62748
+ relativeDirPath: design.relativeDirPath,
62749
+ relativeFilePath: design.relativeFilePath,
62750
+ fileContent,
62751
+ validate,
62752
+ root: false,
62753
+ design: true
62754
+ });
62755
+ }
61395
62756
  if (relativeFilePath === paths.root.relativeFilePath) {
61396
62757
  const relativePath = join(paths.root.relativeDirPath, paths.root.relativeFilePath);
61397
62758
  const fileContent = await readFileContent(join(outputRoot, relativePath));
@@ -61418,18 +62779,34 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61418
62779
  }
61419
62780
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61420
62781
  const paths = this.getSettablePaths({ global });
61421
- const isRoot = relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
62782
+ const design = !global ? paths.design : void 0;
62783
+ const isDesign = design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath;
62784
+ const isRoot = !isDesign && relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
61422
62785
  return new FactorydroidRule({
61423
62786
  outputRoot,
61424
62787
  relativeDirPath,
61425
62788
  relativeFilePath,
61426
62789
  fileContent: "",
61427
62790
  validate: false,
61428
- root: isRoot
62791
+ root: isRoot,
62792
+ design: isDesign
61429
62793
  });
61430
62794
  }
61431
62795
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
62796
+ const frontmatter = rulesyncRule.getFrontmatter();
61432
62797
  const paths = this.getSettablePaths({ global });
62798
+ if (!global && !frontmatter.root && frontmatter.factorydroid?.channel === "design") {
62799
+ const { design } = paths;
62800
+ return new FactorydroidRule({
62801
+ outputRoot,
62802
+ relativeDirPath: design.relativeDirPath,
62803
+ relativeFilePath: design.relativeFilePath,
62804
+ fileContent: rulesyncRule.getBody(),
62805
+ validate,
62806
+ root: false,
62807
+ design: true
62808
+ });
62809
+ }
61433
62810
  return new FactorydroidRule(this.buildToolRuleParamsAgentsmd({
61434
62811
  outputRoot,
61435
62812
  rulesyncRule,
@@ -61439,6 +62816,17 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61439
62816
  }));
61440
62817
  }
61441
62818
  toRulesyncRule() {
62819
+ if (this.design) return new RulesyncRule({
62820
+ outputRoot: process.cwd(),
62821
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
62822
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME,
62823
+ frontmatter: {
62824
+ root: false,
62825
+ targets: ["factorydroid"],
62826
+ factorydroid: { channel: "design" }
62827
+ },
62828
+ body: this.getFileContent()
62829
+ });
61442
62830
  return this.toRulesyncRuleDefault();
61443
62831
  }
61444
62832
  validate() {
@@ -63956,6 +65344,16 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
63956
65344
  ruleDiscoveryMode: "auto"
63957
65345
  }
63958
65346
  }],
65347
+ ["codebuddy", {
65348
+ class: CodebuddyRule,
65349
+ meta: {
65350
+ extension: "md",
65351
+ supportsGlobal: true,
65352
+ ruleDiscoveryMode: "auto",
65353
+ localRootMode: "separate-local-file",
65354
+ localRootFileName: CODEBUDDY_LOCAL_RULE_FILE_NAME
65355
+ }
65356
+ }],
63959
65357
  ["codexcli", {
63960
65358
  class: CodexcliRule,
63961
65359
  meta: {
@@ -63981,6 +65379,15 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
63981
65379
  ruleDiscoveryMode: "auto"
63982
65380
  }
63983
65381
  }],
65382
+ ["crush", {
65383
+ class: CrushRule,
65384
+ meta: {
65385
+ extension: "md",
65386
+ supportsGlobal: true,
65387
+ ruleDiscoveryMode: "auto",
65388
+ collisionPolicy: "fold"
65389
+ }
65390
+ }],
63984
65391
  ["cursor", {
63985
65392
  class: CursorRule,
63986
65393
  meta: {
@@ -64349,6 +65756,10 @@ var RulesProcessor = class extends FeatureProcessor {
64349
65756
  outputFiles,
64350
65757
  convertedRules
64351
65758
  });
65759
+ await this.warnForDeactivatedImportOnlyRoots({
65760
+ toolRules,
65761
+ factory
65762
+ });
64352
65763
  return outputFiles;
64353
65764
  }
64354
65765
  /**
@@ -64570,6 +65981,41 @@ var RulesProcessor = class extends FeatureProcessor {
64570
65981
  }
64571
65982
  }
64572
65983
  /**
65984
+ * Warn when this generate run is about to write a root rule file that will
65985
+ * make the tool stop reading paths it currently reads instead — Junie's
65986
+ * `.junie/rules/*.md` and `.junie/playbook.md` become unreachable the
65987
+ * moment `.junie/AGENTS.md` exists, since Junie reads the root file
65988
+ * exclusively once it is present. `importOnlyRoots` with
65989
+ * `onlyWhenRootAbsent` already models exactly this shape for import; this
65990
+ * reuses the same declaration so the
65991
+ * `generate` path — which never calls `loadToolFiles` and so never reached
65992
+ * the existing import-side warning — surfaces it too. Without this, a repo
65993
+ * that only ever runs `generate` never sees any warning: the deactivated
65994
+ * files stay on disk, untouched and not gitignored, silently unread.
65995
+ */
65996
+ async warnForDeactivatedImportOnlyRoots({ toolRules, factory }) {
65997
+ const rootRule = toolRules.find((rule) => rule.isRoot());
65998
+ if (!rootRule) return;
65999
+ const settablePaths = factory.class.getSettablePaths({ global: this.global });
66000
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
66001
+ if (!importOnlyRoots || importOnlyRoots.length === 0) return;
66002
+ const existingPaths = [];
66003
+ for (const importOnlyRoot of importOnlyRoots) {
66004
+ if (importOnlyRoot.onlyWhenRootAbsent !== true) continue;
66005
+ const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
66006
+ cwd: this.outputRoot,
66007
+ type: "file"
66008
+ });
66009
+ existingPaths.push(...matchedPaths);
66010
+ }
66011
+ if (existingPaths.length === 0) return;
66012
+ const rootFileRelativePath = join(rootRule.getRelativeDirPath(), rootRule.getRelativeFilePath());
66013
+ const names = existingPaths.map((filePath) => stripControlCharacters(relative(this.outputRoot, filePath)));
66014
+ const listedNames = names.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
66015
+ const remainingCount = names.length - listedNames.length;
66016
+ 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.`);
66017
+ }
66018
+ /**
64573
66019
  * Handle localRoot rule generation based on tool target.
64574
66020
  * - `separate-local-file`: writes a dedicated `*.local.md` root file
64575
66021
  * (claudecode/legacy: `./CLAUDE.local.md`, rovodev: `./AGENTS.local.md`)
@@ -64821,6 +66267,26 @@ As this project's AI coding tool, you must follow the additional conventions bel
64821
66267
  }));
64822
66268
  }
64823
66269
  /**
66270
+ * Load and merge rulesync rule files from every configured input root's
66271
+ * `.rulesync/rules/` directory, by relative path, so that a rule with the
66272
+ * same target path from a later root replaces the earlier root's copy
66273
+ * (case-insensitive, matching the intra-root collision handling).
66274
+ *
66275
+ * This is the side-effect-free half of `loadRulesyncFiles`: it does not
66276
+ * warn about a missing root rule or validate `localRoot` placement, so it
66277
+ * is also safe to call from code paths — like
66278
+ * `warnForFoldImportDuplicationRisk` — that only need the merged rule set
66279
+ * and must not trigger `loadRulesyncFiles`'s generate-time checks.
66280
+ */
66281
+ async loadMergedRulesyncRules() {
66282
+ return mergeByCaseInsensitiveIdentity({
66283
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
66284
+ identity: (rule) => rule.getRelativeFilePath(),
66285
+ artifactName: "rule",
66286
+ logger: this.logger
66287
+ });
66288
+ }
66289
+ /**
64824
66290
  * Implementation of abstract method from FeatureProcessor
64825
66291
  * Load and parse rulesync rule files from every configured input root's
64826
66292
  * `.rulesync/rules/` directory, merging by relative path so that a rule
@@ -64828,12 +66294,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
64828
66294
  * copy (case-insensitive, matching the intra-root collision handling).
64829
66295
  */
64830
66296
  async loadRulesyncFiles() {
64831
- const rulesyncRules = mergeByCaseInsensitiveIdentity({
64832
- perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
64833
- identity: (rule) => rule.getRelativeFilePath(),
64834
- artifactName: "rule",
64835
- logger: this.logger
64836
- });
66297
+ const rulesyncRules = await this.loadMergedRulesyncRules();
64837
66298
  const factory = this.getFactory(this.toolTarget);
64838
66299
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
64839
66300
  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}.`);
@@ -64890,6 +66351,46 @@ As this project's AI coding tool, you must follow the additional conventions bel
64890
66351
  });
64891
66352
  }
64892
66353
  /**
66354
+ * Warn when importing a `collisionPolicy: "fold"` target's root file while
66355
+ * `.rulesync/rules/` still holds non-root rules targeting it. A fold target
66356
+ * (codexcli, junie, and others) concatenates every targeted non-root rule
66357
+ * into its one root output file on `generate`. Importing that root file
66358
+ * back therefore re-reads the already-folded content as a single new
66359
+ * rulesync rule, while the original non-root rules stay in place
66360
+ * untouched — the next `generate` folds both together, duplicating the
66361
+ * content once per generate/import cycle with nothing to indicate why.
66362
+ *
66363
+ * This does not attempt to detect or drop the specific duplicated content
66364
+ * (the root file has no marker recording which rule contributed what); it
66365
+ * only surfaces that the cycle produces one, per the "at minimum, warn"
66366
+ * option recorded on issue #2743.
66367
+ *
66368
+ * Only the actual `rulesync import` call site invokes this (and only once
66369
+ * it has confirmed there is something to import) — `loadToolFiles` is also
66370
+ * the entry point for `rulesync convert` and `rulesync fetch`, neither of
66371
+ * which writes to `.rulesync/rules/` or carries this duplication risk.
66372
+ *
66373
+ * Reads via `loadMergedRulesyncRules` rather than `loadRulesyncFiles`
66374
+ * deliberately: this runs before the imported root file is written, so
66375
+ * `.rulesync/rules/` never yet has a root rule targeting this tool, and
66376
+ * `loadRulesyncFiles`'s "no root rule found" warning and `localRoot`
66377
+ * validation (which can throw) would fire spuriously on every fold-tool
66378
+ * import — including ones where nothing is actually misconfigured.
66379
+ *
66380
+ * In global mode, a `localRoot: true` rule is excluded from the
66381
+ * duplication check the same way `loadRulesyncFiles`'s global-mode branch
66382
+ * excludes it from `nonRootRules`: `generate` ignores `localRoot` entirely
66383
+ * in global mode, so such a rule is never actually folded into the global
66384
+ * root output and warning about it here would be inaccurate.
66385
+ */
66386
+ async warnForFoldImportDuplicationRisk() {
66387
+ const factory = this.getFactory(this.toolTarget);
66388
+ if (factory.meta.collisionPolicy !== "fold") return;
66389
+ const nonRootRules = (await this.loadMergedRulesyncRules()).filter((rule) => !rule.getFrontmatter().root && (!this.global || !rule.getFrontmatter().localRoot) && factory.class.isTargetedByRulesyncRule(rule));
66390
+ if (nonRootRules.length === 0) return;
66391
+ 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.`);
66392
+ }
66393
+ /**
64893
66394
  * Implementation of abstract method from FeatureProcessor
64894
66395
  * Load tool-specific rule configurations and parse them into ToolRule instances
64895
66396
  */
@@ -66953,6 +68454,7 @@ async function importRulesCore(params) {
66953
68454
  logger.warn(`No rule files found for ${tool}. Skipping import.`);
66954
68455
  return 0;
66955
68456
  }
68457
+ await rulesProcessor.warnForFoldImportDuplicationRisk();
66956
68458
  const rulesyncFiles = await rulesProcessor.convertToolFilesToRulesyncFiles(toolFiles);
66957
68459
  const { count: writtenCount } = await rulesProcessor.writeAiFiles(rulesyncFiles);
66958
68460
  if (config.getVerbose() && writtenCount > 0) logger.success(`Created ${writtenCount} rule files`);
@@ -67200,6 +68702,6 @@ async function importChecksCore(params) {
67200
68702
  return writtenCount;
67201
68703
  }
67202
68704
  //#endregion
67203
- export { loadYaml as $, ToolTargetSchema as $t, caseFoldIdentity as A, ALL_FEATURES as An, getHomeDirectory as At, RulesyncRuleFrontmatterSchema as B, removeDirectory as Bt, CLAUDECODE_DIR as C, RULESYNC_PERMISSIONS_SCHEMA_URL as Cn, assertWritablePathInsideRoot as Ct, CLAUDECODE_SKILLS_DIR_PATH as D, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Dn, ensureDir as Dt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as E, RULESYNC_SKILLS_RELATIVE_DIR_PATH as En, directoryExists as Et, RulesyncSubagent as F, hasDeceptiveHiddenCharacters as Fn, listFilePathsRecursively as Ft, getRulesyncSourceCandidates as G, resolvePath as Gt, RulesyncMcp as H, removeFile as Ht, RulesyncSubagentFrontmatterSchema as I, quoteForLog as In, listSubdirectoryNames as It, RulesyncCommand as J, writeFileBuffer as Jt, resolveRulesyncSourceWritePath as K, runWithDirectoryRollback as Kt, RulesyncSkill as L, stripControlCharacters as Ln, pathEscapesRoot as Lt, AUGMENTCODE_DIR as M, DEPRECATED_FEATURE_REPLACEMENTS as Mn, isFileSystemError as Mt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as N, formatError as Nn, isSymlink as Nt, FACTORYDROID_DIR as O, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as On, fileExists as Ot, getLocalSkillDirNames as P, truncateText as Pn, listDirectoryEntryNames as Pt, stringifyFrontmatter as Q, PACKAGING_TOOL_TARGETS as Qt, RulesyncSkillFrontmatterSchema as R, stripControlCharactersKeepingLineFeeds as Rn, readFileContent as Rt, CODEXCLI_DIR as S, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as Sn, assertTreeContainsNoSymlinks as St, CLAUDECODE_MEMORIES_DIR_NAME as T, RULESYNC_RULES_RELATIVE_DIR_PATH as Tn, createTempDirectory as Tt, RulesyncIgnore as U, removeFileStrict as Ut, RulesyncPermissions as V, removeDirectoryStrict as Vt, RulesyncHooks as W, removeTempDirectory as Wt, RulesyncCheck as X, ALL_TOOL_TARGETS as Xt, RulesyncCommandFrontmatterSchema as Y, writeFileContent as Yt, RulesyncCheckFrontmatterSchema as Z, ALL_TOOL_TARGETS_WITH_WILDCARD as Zt, CommandsProcessor as _, RULESYNC_MCP_RELATIVE_FILE_PATH as _n, withWarnOnceScope as _t, getProcessorRegistryEntry as a, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as an, CONFLICTING_TARGET_PAIRS as at, ChecksProcessor as b, RULESYNC_PERMISSIONS_FILE_NAME as bn, applyFileMode as bt, RulesProcessor as c, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as cn, SourceEntrySchema as ct, ELLIPSIS_WIDTH as d, RULESYNC_HOOKS_LEGACY_FILE_NAME as dn, JsonLogger as dt, CURATED_RULES_FEATURE_SUBDIR as en, SHARED_USER_MANAGED_CONFIG_PATHS as et, displayWidthOf as f, RULESYNC_HOOKS_RELATIVE_FILE_PATH as fn, WarningCollectingLogger as ft, HooksProcessor as g, RULESYNC_MCP_LEGACY_FILE_NAME as gn, resetRunWarningState as gt, IgnoreProcessor as h, RULESYNC_MCP_FILE_NAME as hn, withFallbackLoggerTarget as ht, inspectInputRoots as i, RULESYNC_CHECKS_RELATIVE_DIR_PATH as in, resolveEffectiveInputRoots as it, groupSpellingsByCaseFoldedIdentity as j, ALL_FEATURES_WITH_WILDCARD as jn, isFileNotFoundError as jt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as k, parseCommaSeparatedList as kn, getFileSize as kt, SubagentsProcessor as l, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as ln, findControlCharacter as lt, McpProcessor as m, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as mn, warnOnConflictingFlags as mt, formatSourceLoadFailure as n, RULESYNC_AIIGNORE_FILE_NAME as nn, ConfigResolver as nt, convertFromTool as o, RULESYNC_CONFIG_RELATIVE_FILE_PATH as on, ConfigFileSchema as ot, shortenToWidth as p, RULESYNC_IGNORE_RELATIVE_FILE_PATH as pn, fallbackLogger as pt, parseJsonc as q, toPosixPath as qt, generate as r, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as rn, mergeInputRootConfigs as rt, isPackagingToolTarget as s, RULESYNC_CONFIG_SCHEMA_URL as sn, GITIGNORE_DESTINATION_KEY as st, importFromTool as t, MAX_FILE_SIZE as tn, SKILL_FILE_NAME as tt, SkillsProcessor as u, RULESYNC_HOOKS_FILE_NAME as un, ConsoleLogger as ut, QWENCODE_DIR as v, RULESYNC_MCP_SCHEMA_URL as vn, CLIError as vt, CLAUDECODE_LOCAL_RULE_FILE_NAME as w, RULESYNC_RELATIVE_DIR_PATH as wn, checkPathTraversal as wt, CODEXCLI_BASH_RULES_FILE_NAME as x, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as xn, assertDirectoryIfExists as xt, QWENCODE_LOCAL_RULE_FILE_NAME as y, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as yn, ErrorCodes as yt, RulesyncRule as z, stripHiddenCharacters as zn, readFileContentOrNull as zt };
68705
+ export { RulesyncCheckFrontmatterSchema as $, ALL_TOOL_TARGETS_WITH_WILDCARD as $t, FACTORYDROID_DIR as A, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as An, fileExists as At, RulesyncSkillFrontmatterSchema as B, stripControlCharacters as Bn, readFileContent as Bt, CODEXCLI_BASH_RULES_FILE_NAME as C, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as Cn, assertDirectoryIfExists as Ct, CLAUDECODE_MEMORIES_DIR_NAME as D, RULESYNC_RULES_RELATIVE_DIR_PATH as Dn, createTempDirectory as Dt, CLAUDECODE_LOCAL_RULE_FILE_NAME as E, RULESYNC_RELATIVE_DIR_PATH as En, checkPathTraversal as Et, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as F, formatError as Fn, isSymlink as Ft, RulesyncIgnore as G, removeFileStrict as Gt, RulesyncRuleFrontmatterSchema as H, stripHiddenCharacters as Hn, removeDirectory as Ht, getLocalSkillDirNames as I, truncateText as In, listDirectoryEntryNames as It, resolveRulesyncSourceWritePath as J, runWithDirectoryRollback as Jt, RulesyncHooks as K, removeTempDirectory as Kt, RulesyncSubagent as L, hasDeceptiveHiddenCharacters as Ln, listFilePathsRecursively as Lt, caseFoldIdentity as M, ALL_FEATURES as Mn, getHomeDirectory as Mt, groupSpellingsByCaseFoldedIdentity as N, ALL_FEATURES_WITH_WILDCARD as Nn, isFileNotFoundError as Nt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as O, RULESYNC_SKILLS_RELATIVE_DIR_PATH as On, directoryExists as Ot, AUGMENTCODE_DIR as P, DEPRECATED_FEATURE_REPLACEMENTS as Pn, isFileSystemError as Pt, RulesyncCheck as Q, ALL_TOOL_TARGETS as Qt, RulesyncSubagentFrontmatterSchema as R, hasEnclosingMarkOutsideKeycap as Rn, listSubdirectoryNames as Rt, ChecksProcessor as S, RULESYNC_PERMISSIONS_FILE_NAME as Sn, applyFileMode as St, CLAUDECODE_DIR as T, RULESYNC_PERMISSIONS_SCHEMA_URL as Tn, assertWritablePathInsideRoot as Tt, RulesyncPermissions as U, removeDirectoryStrict as Ut, RulesyncRule as V, stripControlCharactersKeepingLineFeeds as Vn, readFileContentOrNull as Vt, RulesyncMcp as W, removeFile as Wt, RulesyncCommand as X, writeFileBuffer as Xt, parseJsonc as Y, toPosixPath as Yt, RulesyncCommandFrontmatterSchema as Z, writeFileContent as Zt, IgnoreProcessor as _, RULESYNC_MCP_FILE_NAME as _n, withFallbackLoggerTarget as _t, getProcessorRegistryEntry as a, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as an, mergeInputRootConfigs as at, QWENCODE_DIR as b, RULESYNC_MCP_SCHEMA_URL as bn, CLIError as bt, RulesProcessor as c, RULESYNC_CONFIG_RELATIVE_FILE_PATH as cn, ConfigFileSchema as ct, CODEBUDDY_DIR as d, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as dn, findControlCharacter as dt, PACKAGING_TOOL_TARGETS as en, stringifyFrontmatter as et, CODEBUDDY_LOCAL_RULE_FILE_NAME as f, RULESYNC_HOOKS_FILE_NAME as fn, ConsoleLogger as ft, McpProcessor as g, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as gn, warnOnConflictingFlags as gt, shortenToWidth as h, RULESYNC_IGNORE_RELATIVE_FILE_PATH as hn, fallbackLogger as ht, inspectInputRoots as i, RULESYNC_AIIGNORE_FILE_NAME as in, ConfigResolver as it, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as j, parseCommaSeparatedList as jn, getFileSize as jt, CLAUDECODE_SKILLS_DIR_PATH as k, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as kn, ensureDir as kt, SubagentsProcessor as l, RULESYNC_CONFIG_SCHEMA_URL as ln, GITIGNORE_DESTINATION_KEY as lt, displayWidthOf as m, RULESYNC_HOOKS_RELATIVE_FILE_PATH as mn, WarningCollectingLogger as mt, formatSourceLoadFailure as n, CURATED_RULES_FEATURE_SUBDIR as nn, SHARED_USER_MANAGED_CONFIG_PATHS as nt, convertFromTool as o, RULESYNC_CHECKS_RELATIVE_DIR_PATH as on, resolveEffectiveInputRoots as ot, ELLIPSIS_WIDTH as p, RULESYNC_HOOKS_LEGACY_FILE_NAME as pn, JsonLogger as pt, getRulesyncSourceCandidates as q, resolvePath as qt, generate as r, MAX_FILE_SIZE as rn, SKILL_FILE_NAME as rt, isPackagingToolTarget as s, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as sn, CONFLICTING_TARGET_PAIRS as st, importFromTool as t, ToolTargetSchema as tn, loadYaml as tt, SkillsProcessor as u, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as un, SourceEntrySchema as ut, HooksProcessor as v, RULESYNC_MCP_LEGACY_FILE_NAME as vn, resetRunWarningState as vt, CODEXCLI_DIR as w, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as wn, assertTreeContainsNoSymlinks as wt, QWENCODE_LOCAL_RULE_FILE_NAME as x, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as xn, ErrorCodes as xt, CommandsProcessor as y, RULESYNC_MCP_RELATIVE_FILE_PATH as yn, withWarnOnceScope as yt, RulesyncSkill as z, quoteForLog as zn, pathEscapesRoot as zt };
67204
68706
 
67205
- //# sourceMappingURL=import-5_n-Y8N6.js.map
68707
+ //# sourceMappingURL=import-DUE1P1zV.js.map