rulesync 38.0.0 → 40.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -457,36 +457,36 @@ const isFeatureValueEnabled = (value) => {
457
457
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
458
458
  //#endregion
459
459
  //#region src/constants/rulesync-paths.ts
460
- const { join: join$426 } = node_path.posix;
460
+ const { join: join$441 } = node_path.posix;
461
461
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
462
462
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
463
463
  const RULESYNC_USER_CONFIG_DIR_NAME = "rulesync";
464
464
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
465
465
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
466
466
  const RULES_FEATURE_SUBDIR = "rules";
467
- const CURATED_RULES_FEATURE_SUBDIR = join$426(RULES_FEATURE_SUBDIR, ".curated");
467
+ const CURATED_RULES_FEATURE_SUBDIR = join$441(RULES_FEATURE_SUBDIR, ".curated");
468
468
  const COMMANDS_FEATURE_SUBDIR = "commands";
469
469
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
470
470
  const CHECKS_FEATURE_SUBDIR = "checks";
471
471
  const SKILLS_FEATURE_SUBDIR = "skills";
472
- const CURATED_SKILLS_FEATURE_SUBDIR = join$426(SKILLS_FEATURE_SUBDIR, ".curated");
473
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
474
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
475
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
476
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
477
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
478
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
479
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
480
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
481
- join$426(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
482
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
483
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
472
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$441(SKILLS_FEATURE_SUBDIR, ".curated");
473
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
474
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
475
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
476
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
477
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
478
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
479
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
480
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
481
+ join$441(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
482
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
483
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
484
484
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
485
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
485
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
486
486
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
487
487
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
488
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
489
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$426(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
488
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
489
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$441(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
490
490
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
491
491
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
492
492
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -544,6 +544,7 @@ const rulesProcessorToolTargetTuple = [
544
544
  "cursor",
545
545
  "deepagents",
546
546
  "factorydroid",
547
+ "firebender",
547
548
  "goose",
548
549
  "grokcli",
549
550
  "gitlabduo",
@@ -573,6 +574,7 @@ const rulesProcessorToolTargetTuple = [
573
574
  "trae",
574
575
  "vibe",
575
576
  "warp",
577
+ "xum",
576
578
  "devin",
577
579
  "devin-plugin",
578
580
  "zcode",
@@ -582,6 +584,7 @@ const rulesProcessorToolTargetTuple = [
582
584
  ];
583
585
  const ignoreProcessorToolTargetTuple = [
584
586
  "aiassistant",
587
+ "androidstudio",
585
588
  "antigravity-cli",
586
589
  "augmentcode",
587
590
  "bob",
@@ -592,6 +595,7 @@ const ignoreProcessorToolTargetTuple = [
592
595
  "continue",
593
596
  "crush",
594
597
  "cursor",
598
+ "firebender",
595
599
  "grokcli",
596
600
  "hermesagent",
597
601
  "junie",
@@ -639,6 +643,7 @@ const mcpProcessorToolTargetTuple = [
639
643
  "deepagents",
640
644
  "dsh",
641
645
  "factorydroid",
646
+ "firebender",
642
647
  "goose",
643
648
  "grokcli",
644
649
  "gitlabduo",
@@ -668,6 +673,7 @@ const mcpProcessorToolTargetTuple = [
668
673
  "vibe-plugin",
669
674
  "warp",
670
675
  "warpcli",
676
+ "xum",
671
677
  "devin",
672
678
  "devin-plugin",
673
679
  "zcode",
@@ -694,6 +700,7 @@ const commandsProcessorToolTargetTuple = [
694
700
  "copilot",
695
701
  "cursor",
696
702
  "factorydroid",
703
+ "firebender",
697
704
  "goose",
698
705
  "grokcli",
699
706
  "gitlabduo",
@@ -749,6 +756,7 @@ const subagentsProcessorToolTargetTuple = [
749
756
  "devin",
750
757
  "devin-plugin",
751
758
  "factorydroid",
759
+ "firebender",
752
760
  "goose",
753
761
  "grokcli",
754
762
  "hermesagent",
@@ -773,6 +781,7 @@ const subagentsProcessorToolTargetTuple = [
773
781
  "takt",
774
782
  "vibe",
775
783
  "vibe-plugin",
784
+ "xum",
776
785
  "zcode",
777
786
  "zcode-plugin"
778
787
  ];
@@ -780,6 +789,7 @@ const skillsProcessorToolTargetTuple = [
780
789
  "agentsmd",
781
790
  "agentsskills",
782
791
  "aiassistant",
792
+ "androidstudio",
783
793
  "amp",
784
794
  "antigravity-cli",
785
795
  "antigravity-ide",
@@ -803,6 +813,7 @@ const skillsProcessorToolTargetTuple = [
803
813
  "cursor",
804
814
  "deepagents",
805
815
  "factorydroid",
816
+ "firebender",
806
817
  "goose",
807
818
  "grokcli",
808
819
  "gitlabduo",
@@ -832,6 +843,7 @@ const skillsProcessorToolTargetTuple = [
832
843
  "vibe",
833
844
  "vibe-plugin",
834
845
  "warp",
846
+ "xum",
835
847
  "devin",
836
848
  "devin-plugin",
837
849
  "zcode",
@@ -866,6 +878,7 @@ const hooksProcessorToolTargetTuple = [
866
878
  "pi",
867
879
  "pool",
868
880
  "factorydroid",
881
+ "firebender",
869
882
  "goose",
870
883
  "hermesagent",
871
884
  "kimi-code",
@@ -1057,6 +1070,57 @@ async function assertWritablePathInsideRoot(params) {
1057
1070
  existingPath = parentPath;
1058
1071
  }
1059
1072
  }
1073
+ /**
1074
+ * Thrown by {@link assertPathResolvesInsideRoot} when a symbolic link leads
1075
+ * the target outside the root, or the target is reached through a dangling
1076
+ * link. Any other error it throws comes from the filesystem itself.
1077
+ */
1078
+ var PathOutsideRootError = class extends Error {
1079
+ constructor(message, options) {
1080
+ super(message, options);
1081
+ this.name = "PathOutsideRootError";
1082
+ }
1083
+ };
1084
+ /**
1085
+ * Refuse a write or removal target whose real location is outside `rootPath`.
1086
+ *
1087
+ * Unlike {@link assertWritablePathInsideRoot}, a symbolic link on the way is
1088
+ * allowed as long as it resolves inside the root: projects deliberately link a
1089
+ * skill directory to a shared one next to it. What this stops is a link that
1090
+ * leads elsewhere — a cloned repository committing `.rulesync/skills/x ->
1091
+ * ~/.ssh`, say — which a textual check such as {@link checkPathTraversal}
1092
+ * cannot see. The deepest existing ancestor of `targetPath` is resolved, and
1093
+ * an existing target that is itself a link is resolved too.
1094
+ */
1095
+ async function assertPathResolvesInsideRoot(params) {
1096
+ const { rootPath, targetPath } = params;
1097
+ let realRoot;
1098
+ try {
1099
+ realRoot = await (0, node_fs_promises.realpath)(rootPath);
1100
+ } catch (error) {
1101
+ if (isFileNotFoundError(error)) {
1102
+ if (await isSymlink(rootPath)) throw new PathOutsideRootError(`Refusing to write through a dangling symbolic link: ${rootPath}.`, { cause: error });
1103
+ return;
1104
+ }
1105
+ throw error;
1106
+ }
1107
+ let existingPath = (0, node_path.resolve)(targetPath);
1108
+ while (true) {
1109
+ let realExisting;
1110
+ try {
1111
+ realExisting = await (0, node_fs_promises.realpath)(existingPath);
1112
+ } catch (error) {
1113
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
1114
+ if (await isSymlink(existingPath)) throw new PathOutsideRootError(`Refusing to write through a dangling symbolic link: ${targetPath}.`, { cause: error });
1115
+ const parentPath = (0, node_path.dirname)(existingPath);
1116
+ if (parentPath === existingPath) throw error;
1117
+ existingPath = parentPath;
1118
+ continue;
1119
+ }
1120
+ if (pathEscapesRoot((0, node_path.relative)(realRoot, realExisting))) throw new PathOutsideRootError(`Path resolves outside ${rootPath} through a symbolic link: ${targetPath}.`);
1121
+ return;
1122
+ }
1123
+ }
1060
1124
  async function assertTreeContainsNoSymlinks(dirPath) {
1061
1125
  for (const entry of await (0, node_fs_promises.readdir)(dirPath, { withFileTypes: true })) {
1062
1126
  const entryPath = (0, node_path.join)(dirPath, entry.name);
@@ -3780,6 +3844,8 @@ const SHARED_USER_MANAGED_CONFIG_PATHS = [
3780
3844
  ".devin/config.json",
3781
3845
  ".dsh/cordis.patch.yml",
3782
3846
  ".factory/settings.json",
3847
+ "firebender.json",
3848
+ ".firebender/firebender.json",
3783
3849
  ".grok/config.toml",
3784
3850
  ".grok/sandbox.toml",
3785
3851
  ".letta/settings.json",
@@ -4241,14 +4307,103 @@ function stringifyFrontmatter(body, frontmatter, options) {
4241
4307
  } } });
4242
4308
  return gray_matter.default.stringify(file, cleanFrontmatter);
4243
4309
  }
4310
+ /**
4311
+ * Thrown in place of gray-matter's built-in JavaScript engine.
4312
+ *
4313
+ * gray-matter picks the parser from the text after the opening fence, so a
4314
+ * file that opens with `---js` or `---javascript` has its frontmatter handed
4315
+ * to `eval()`. Frontmatter here comes from third-party skills, fetched sources
4316
+ * and untrusted repositories, so that would run arbitrary code on the machine
4317
+ * of whoever generates or imports it. Every engine name that resolves to code
4318
+ * execution is mapped to this refusal; {@link assertYamlFrontmatterLanguage}
4319
+ * refuses those fences earlier still, so this is the second line of defense.
4320
+ */
4321
+ function refuseExecutableFrontmatter() {
4322
+ throw new Error("Frontmatter written in an executable language (JavaScript / CoffeeScript) is not supported; use YAML");
4323
+ }
4324
+ const REFUSED_FRONTMATTER_ENGINE = {
4325
+ parse: refuseExecutableFrontmatter,
4326
+ stringify: refuseExecutableFrontmatter
4327
+ };
4328
+ /**
4329
+ * Engines passed to every gray-matter parse. Supplying them merges over
4330
+ * gray-matter's defaults, so the JavaScript engine (and the CoffeeScript names
4331
+ * a plugin could register) can never be reached through an alias.
4332
+ *
4333
+ * `patches/gray-matter@4.0.3.patch` also deletes the JavaScript engine, but a
4334
+ * pnpm patch only applies inside this repository: gray-matter is an external
4335
+ * dependency of the published package, so an installed rulesync runs the
4336
+ * unpatched gray-matter, and this override plus the language check are what
4337
+ * protect it. Exported for the test that pins that.
4338
+ */
4339
+ const FRONTMATTER_PARSE_OPTIONS = { engines: {
4340
+ javascript: REFUSED_FRONTMATTER_ENGINE,
4341
+ js: REFUSED_FRONTMATTER_ENGINE,
4342
+ coffee: REFUSED_FRONTMATTER_ENGINE,
4343
+ coffeescript: REFUSED_FRONTMATTER_ENGINE,
4344
+ cson: REFUSED_FRONTMATTER_ENGINE
4345
+ } };
4346
+ /** Frontmatter languages accepted after the opening `---` fence. */
4347
+ const ALLOWED_FRONTMATTER_LANGUAGE_PATTERN = /^(?:ya?ml)?$/i;
4348
+ /**
4349
+ * Read the opening frontmatter fence the way gray-matter does, or return
4350
+ * `undefined` when gray-matter would see no frontmatter at all.
4351
+ *
4352
+ * gray-matter strips one byte order mark, treats `---` as a fence only when a
4353
+ * fourth `-` does not follow, and takes the language from the text between the
4354
+ * fence and the first `\r?\n`, trimmed — dropping the last character when
4355
+ * there is no line break at all. Every check below that has to agree with
4356
+ * gray-matter on where the block is (the language check, the size guard, the
4357
+ * repair pass) goes through this one reading, so none of them can be bypassed
4358
+ * by a fence line gray-matter accepts and a stricter pattern would not (a
4359
+ * stray `\r` before the line break, say).
4360
+ */
4361
+ function readFrontmatterFence(content) {
4362
+ const offset = content.startsWith("") ? 1 : 0;
4363
+ const text = content.slice(offset);
4364
+ if (!text.startsWith("---") || text.charAt(3) === "-") return;
4365
+ const afterFence = text.slice(3);
4366
+ const lineBreak = /\r?\n/.exec(afterFence);
4367
+ const language = afterFence.slice(0, lineBreak ? lineBreak.index : -1).trim();
4368
+ const lineBreakStart = lineBreak ? offset + 3 + lineBreak.index : void 0;
4369
+ return {
4370
+ language,
4371
+ lineBreakStart,
4372
+ lineEnd: lineBreak && lineBreakStart !== void 0 ? lineBreakStart + lineBreak[0].length : void 0
4373
+ };
4374
+ }
4375
+ /**
4376
+ * Refuse a frontmatter fence that names any language other than YAML.
4377
+ *
4378
+ * Only YAML is ever written by rulesync or by the tools it targets, so anything
4379
+ * else — `---js` above all, whose block gray-matter would `eval()` — is
4380
+ * rejected before gray-matter sees the content.
4381
+ */
4382
+ function assertYamlFrontmatterLanguage(content) {
4383
+ const fence = readFrontmatterFence(content);
4384
+ if (fence && !ALLOWED_FRONTMATTER_LANGUAGE_PATTERN.test(fence.language)) throw new Error(`Unsupported frontmatter language ${JSON.stringify(fence.language.slice(0, 40))}; only YAML frontmatter is supported`);
4385
+ }
4386
+ /**
4387
+ * The number of characters gray-matter would hand to the YAML parser as the
4388
+ * frontmatter block. Unlike {@link findFrontmatterBlockBounds}, a missing
4389
+ * closing fence does not make this zero: gray-matter then parses everything
4390
+ * after the fence line, so that is what the size guard has to measure.
4391
+ */
4392
+ function measureFrontmatterBlockChars(content) {
4393
+ const fence = readFrontmatterFence(content);
4394
+ if (fence?.lineBreakStart === void 0) return 0;
4395
+ const rest = content.slice(fence.lineBreakStart);
4396
+ const closeIndex = rest.indexOf("\n---");
4397
+ return closeIndex === -1 ? rest.length : closeIndex;
4398
+ }
4244
4399
  function parseFrontmatter(content, filePath) {
4245
4400
  let frontmatter;
4246
4401
  let body;
4247
4402
  let hasFrontmatter;
4248
4403
  try {
4249
- const bounds = findFrontmatterBlockBounds(content);
4250
- 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)`);
4251
- const result = (0, gray_matter.default)(content, {});
4404
+ assertYamlFrontmatterLanguage(content);
4405
+ if (measureFrontmatterBlockChars(content) > 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)`);
4406
+ const result = (0, gray_matter.default)(content, FRONTMATTER_PARSE_OPTIONS);
4252
4407
  frontmatter = deepRemoveNullishObject(result.data);
4253
4408
  body = result.content;
4254
4409
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
@@ -4307,22 +4462,28 @@ function repairFrontmatterLine(line) {
4307
4462
  }
4308
4463
  /**
4309
4464
  * Locate a raw `---`-delimited frontmatter block's bounds within `content`,
4310
- * without parsing it. Shared by the size guard in {@link parseFrontmatter} and
4311
- * the YAML repair pass below, so both agree on exactly what gray-matter would
4312
- * treat as the block: gray-matter ends it at the first `\n---`, with no
4313
- * requirement that the delimiter be alone on its line, so a stricter pattern
4314
- * here would run past gray-matter's delimiter and act on text that is really
4315
- * the body.
4465
+ * without parsing it, for the YAML repair pass below and the rule loaders that
4466
+ * pre-process `globs:`. The opening fence is read exactly as gray-matter reads
4467
+ * it ({@link readFrontmatterFence}); an explicit `yaml` / `yml` language is
4468
+ * still YAML, any other language yields no bounds. gray-matter ends the block
4469
+ * at the first `\n---`, with no requirement that the delimiter be alone on its
4470
+ * line, so a stricter pattern here would run past gray-matter's delimiter and
4471
+ * act on text that is really the body. A block with no closing fence has no
4472
+ * bounds; the size guard measures that case separately.
4316
4473
  */
4317
4474
  function findFrontmatterBlockBounds(content) {
4318
- const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
4319
- if (!opening) return;
4320
- const blockStart = opening[0].length;
4321
- const closing = /\r?\n---/.exec(content.slice(blockStart));
4475
+ const fence = readFrontmatterFence(content);
4476
+ if (fence?.lineBreakStart === void 0 || fence.lineEnd === void 0 || !ALLOWED_FRONTMATTER_LANGUAGE_PATTERN.test(fence.language)) return;
4477
+ const blockStart = fence.lineEnd;
4478
+ const closing = /\r?\n---/.exec(content.slice(fence.lineBreakStart));
4322
4479
  if (!closing) return;
4480
+ if (closing.index === 0) return {
4481
+ blockStart,
4482
+ blockEnd: blockStart
4483
+ };
4323
4484
  return {
4324
4485
  blockStart,
4325
- blockEnd: blockStart + closing.index
4486
+ blockEnd: fence.lineBreakStart + closing.index
4326
4487
  };
4327
4488
  }
4328
4489
  /**
@@ -4354,8 +4515,9 @@ function repairMalformedFrontmatterYaml(content) {
4354
4515
  * narrow — one pass, top-level entries only, and the original error is what
4355
4516
  * surfaces if it does not help, so a genuinely broken file still fails with the
4356
4517
  * message that describes what is actually wrong with it. A file with no closing
4357
- * `---`, or one whose opening fence carries a language tag, is not repaired at
4358
- * all: neither is a frontmatter block gray-matter would have read.
4518
+ * `---` is not repaired at all, and one whose opening fence names a language
4519
+ * other than YAML is refused outright (an explicit `---yaml` / `---yml` fence
4520
+ * is repaired like a plain one).
4359
4521
  *
4360
4522
  * @see https://agentskills.io/client-implementation/adding-skills-support
4361
4523
  */
@@ -4720,6 +4882,29 @@ const CURSOR_HOOK_EVENTS = [
4720
4882
  "workspaceOpen"
4721
4883
  ];
4722
4884
  /**
4885
+ * Hook events supported by Firebender.
4886
+ *
4887
+ * Firebender's `hooks.json` follows Cursor's layout, and every event it fires
4888
+ * is spelled like its canonical counterpart, so no name mapping is needed.
4889
+ * @see https://docs.firebender.com/multi-agent/hooks
4890
+ */
4891
+ const FIREBENDER_HOOK_EVENTS = [
4892
+ "sessionStart",
4893
+ "sessionEnd",
4894
+ "subagentStart",
4895
+ "subagentStop",
4896
+ "preToolUse",
4897
+ "postToolUse",
4898
+ "beforeShellExecution",
4899
+ "afterShellExecution",
4900
+ "beforeMCPExecution",
4901
+ "afterMCPExecution",
4902
+ "beforeReadFile",
4903
+ "afterFileEdit",
4904
+ "preCompact",
4905
+ "stop"
4906
+ ];
4907
+ /**
4723
4908
  * Hook events supported by Claude Code.
4724
4909
  *
4725
4910
  * Covers the full documented event surface.
@@ -5000,6 +5185,40 @@ const COPILOT_HOOK_EVENTS = [
5000
5185
  "userPromptExpansion"
5001
5186
  ];
5002
5187
  /**
5188
+ * The {@link COPILOT_HOOK_EVENTS} whose hook entries honor an optional
5189
+ * `matcher` regex. The hooks reference documents matcher filtering on
5190
+ * `preToolUse` / `postToolUse` / `permissionRequest` (tool name), `preCompact`
5191
+ * (trigger) and `subagentStart` (agent name), plus `notification`; the last
5192
+ * two are not authorable for `copilot`, so they are not listed here. VS Code's
5193
+ * migration guide confirms the Copilot harness "applies documented matcher
5194
+ * rules" (only the legacy Local harness ignores them).
5195
+ *
5196
+ * @see https://docs.github.com/en/copilot/reference/hooks-reference
5197
+ * @see https://code.visualstudio.com/docs/agent-customization/migrate-customizations
5198
+ */
5199
+ const COPILOT_MATCHER_HOOK_EVENTS = [
5200
+ "preToolUse",
5201
+ "postToolUse",
5202
+ "preCompact",
5203
+ "subagentStart"
5204
+ ];
5205
+ /**
5206
+ * The {@link COPILOTCLI_HOOK_EVENTS} whose hook entries honor an optional
5207
+ * `matcher` regex: the shared {@link COPILOT_MATCHER_HOOK_EVENTS} plus the two
5208
+ * CLI-only events, `permissionRequest` (tool name) and `notification`
5209
+ * (notification type).
5210
+ *
5211
+ * Confirmed by the upstream changelog: v1.0.36 (2026-04-24, `preToolUse.matcher`
5212
+ * honored) and v1.0.63 (2026-06-15, "PostToolUse hook matchers (e.g.
5213
+ * `Edit|Write`) are now honored instead of silently dropped").
5214
+ * @see https://docs.github.com/en/copilot/reference/hooks-reference
5215
+ */
5216
+ const COPILOTCLI_MATCHER_HOOK_EVENTS = [
5217
+ ...COPILOT_MATCHER_HOOK_EVENTS,
5218
+ "permissionRequest",
5219
+ "notification"
5220
+ ];
5221
+ /**
5003
5222
  * Hook events supported by the GitHub Copilot CLI (`copilotcli-hooks.ts`).
5004
5223
  *
5005
5224
  * The CLI documents a wider event surface than the shared cloud-agent set, so
@@ -5153,12 +5372,15 @@ const KIRO_IDE_HOOK_EVENTS = [
5153
5372
  /**
5154
5373
  * Hook events supported by Google Antigravity (both the IDE and the CLI).
5155
5374
  *
5156
- * Antigravity exposes a Claude-style hooks surface covering the five
5157
- * tool/model/turn lifecycle events it documents: `PreToolUse`, `PostToolUse`,
5158
- * `PreInvocation`, `PostInvocation`, and `Stop`. The model-invocation events
5159
- * (`PreInvocation`/`PostInvocation`) and `Stop` are matcher-less handler lists.
5375
+ * Antigravity exposes a Claude-style hooks surface covering the six
5376
+ * session/tool/model/turn lifecycle events it documents: `SessionStart`,
5377
+ * `PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, and `Stop`.
5378
+ * `SessionStart`, the model-invocation events (`PreInvocation`/`PostInvocation`)
5379
+ * and `Stop` are matcher-less handler lists.
5380
+ * https://antigravity.google/docs/hooks
5160
5381
  */
5161
5382
  const ANTIGRAVITY_HOOK_EVENTS = [
5383
+ "sessionStart",
5162
5384
  "preToolUse",
5163
5385
  "postToolUse",
5164
5386
  "preModelInvocation",
@@ -5931,8 +6153,8 @@ const CONTINUE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANO
5931
6153
  /**
5932
6154
  * Hook events supported by Hermes Agent's native Shell Hooks system.
5933
6155
  *
5934
- * Hermes validates hook events against a fixed `VALID_HOOKS` set — 44 entries as
5935
- * of v0.21.6 (tag `v0.21.6`); see {@link HERMESAGENT_NATIVE_HOOK_EVENTS} for the
6156
+ * Hermes validates hook events against a fixed `VALID_HOOKS` set — 46 entries as
6157
+ * of v0.22.3 (tag `v0.22.3`); see {@link HERMESAGENT_NATIVE_HOOK_EVENTS} for the
5936
6158
  * full list. Only the events with a clean 1:1 canonical equivalent are mapped
5937
6159
  * here. All other native events round-trip through `hermesagent.hooks`.
5938
6160
  * @see https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
@@ -5956,15 +6178,16 @@ const HERMESAGENT_HOOK_EVENTS = [
5956
6178
  * observers, `gateway_platform_event` and `transform_api_error_classification`.
5957
6179
  * Shell hooks are gated on the same set.
5958
6180
  *
5959
- * Verified against the tag `v0.21.6`: `VALID_HOOKS` holds 44 entries, grown
6181
+ * Verified against the tag `v0.22.3`: `VALID_HOOKS` holds 46 entries, grown
5960
6182
  * from 23 at `v2026.8.3` (v0.20.0) and 37 at `v2026.8.16` (v0.20.2) — v0.21
5961
6183
  * added `agent_loop_stopped` and `on_room_member_activity`, v0.21.5 added
5962
- * `pre_auxiliary_call` and `post_auxiliary_call`, and v0.21.6 added
6184
+ * `pre_auxiliary_call` and `post_auxiliary_call`, v0.21.6 added
5963
6185
  * `post_gateway_admission`, `on_human_input_request` and
5964
- * `on_human_input_resolved`. This list holds 43 of them — see
6186
+ * `on_human_input_resolved`, and v0.22.3 added `on_session_delete` and
6187
+ * `post_compaction`. This list holds 45 of them — see
5965
6188
  * {@link HERMESAGENT_SHELL_UNSUPPORTED_HOOK_EVENTS} for the exclusion.
5966
6189
  *
5967
- * @see https://github.com/NousResearch/hermes-agent/blob/v0.21.6/hermes_cli/plugins.py
6190
+ * @see https://github.com/NousResearch/hermes-agent/blob/v0.22.3/hermes_cli/plugins.py
5968
6191
  */
5969
6192
  const HERMESAGENT_NATIVE_HOOK_EVENTS = [
5970
6193
  "pre_tool_call",
@@ -5988,6 +6211,7 @@ const HERMESAGENT_NATIVE_HOOK_EVENTS = [
5988
6211
  "on_session_end",
5989
6212
  "on_session_finalize",
5990
6213
  "on_session_reset",
6214
+ "on_session_delete",
5991
6215
  "on_skill_lifecycle",
5992
6216
  "subagent_start",
5993
6217
  "subagent_stop",
@@ -6009,7 +6233,8 @@ const HERMESAGENT_NATIVE_HOOK_EVENTS = [
6009
6233
  "on_kanban_task_updated",
6010
6234
  "on_kanban_dispatch_tick",
6011
6235
  "gateway_platform_event",
6012
- "pre_command"
6236
+ "pre_command",
6237
+ "post_compaction"
6013
6238
  ];
6014
6239
  /**
6015
6240
  * `VALID_HOOKS` entries a SHELL hook may not register for. `VALID_HOOKS` doubles
@@ -6021,8 +6246,8 @@ const HERMESAGENT_NATIVE_HOOK_EVENTS = [
6021
6246
  * rulesync only ever writes shell hooks, so these are excluded from
6022
6247
  * {@link HERMESAGENT_NATIVE_HOOK_EVENTS} and warned about with their own message.
6023
6248
  *
6024
- * @see https://github.com/NousResearch/hermes-agent/blob/v0.21.6/hermes_cli/plugins.py — `SHELL_UNSUPPORTED_HOOKS`
6025
- * @see https://github.com/NousResearch/hermes-agent/blob/v0.21.6/agent/shell_hooks.py — `_parse_hooks_block`
6249
+ * @see https://github.com/NousResearch/hermes-agent/blob/v0.22.3/hermes_cli/plugins.py — `SHELL_UNSUPPORTED_HOOKS`
6250
+ * @see https://github.com/NousResearch/hermes-agent/blob/v0.22.3/agent/shell_hooks.py — `_parse_hooks_block`
6026
6251
  */
6027
6252
  const HERMESAGENT_SHELL_UNSUPPORTED_HOOK_EVENTS = ["transform_api_error_classification"];
6028
6253
  /**
@@ -6067,6 +6292,7 @@ const HooksConfigSchema = zod_mini.z.looseObject({
6067
6292
  version: zod_mini.z.optional(zod_mini.z.number()),
6068
6293
  hooks: canonicalHooksRecordSchema,
6069
6294
  cursor: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
6295
+ firebender: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
6070
6296
  claudecode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
6071
6297
  copilot: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
6072
6298
  copilotcli: zod_mini.z.optional(zod_mini.z.looseObject({
@@ -6203,10 +6429,11 @@ const CANONICAL_TO_AUGMENTCODE_EVENT_NAMES = {
6203
6429
  const AUGMENTCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_AUGMENTCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
6204
6430
  /**
6205
6431
  * Map canonical camelCase event names to Antigravity PascalCase.
6206
- * Antigravity uses the same PascalCase names as Claude for its tool/turn events,
6432
+ * Antigravity uses the same PascalCase names as Claude for its session/tool/turn events,
6207
6433
  * plus `PreInvocation`/`PostInvocation` for the model-invocation lifecycle.
6208
6434
  */
6209
6435
  const CANONICAL_TO_ANTIGRAVITY_EVENT_NAMES = {
6436
+ sessionStart: "SessionStart",
6210
6437
  preToolUse: "PreToolUse",
6211
6438
  postToolUse: "PostToolUse",
6212
6439
  preModelInvocation: "PreInvocation",
@@ -7170,7 +7397,7 @@ const McpServerSchema = zod_mini.z.looseObject({
7170
7397
  crushSessionless: zod_mini.z.optional(zod_mini.z.boolean()),
7171
7398
  crushChannelEnabled: zod_mini.z.optional(zod_mini.z.boolean()),
7172
7399
  crushChannelReply: zod_mini.z.optional(CrushChannelReplySchema),
7173
- zcodeOauth: zod_mini.z.optional(zod_mini.z.looseObject({ type: zod_mini.z.string() })),
7400
+ zcodeOauth: zod_mini.z.optional(zod_mini.z.looseObject({})),
7174
7401
  poolAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
7175
7402
  headers: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
7176
7403
  /**
@@ -7225,14 +7452,19 @@ const toolScopedMcpSchema = zod_mini.z.looseObject({ mcpServers: zod_mini.z.opti
7225
7452
  /**
7226
7453
  * Kimi Code's tool-scoped block also carries the `[mcp]` defaults from its
7227
7454
  * shared user config: connect and per-tool-call timeouts that apply to *every*
7228
- * MCP server, including ones rulesync did not write. Per-server
7229
- * `startupTimeoutMs` / `toolTimeoutMs` in `mcpServers` still win.
7455
+ * MCP server, including ones rulesync did not write, and (Kimi Code 2.3.0)
7456
+ * `defaultDeferred`, which loads every server's tools on demand unless the
7457
+ * server opts out. Per-server `startupTimeoutMs` / `toolTimeoutMs` / `deferred`
7458
+ * in `mcpServers` still win. `defaultDeferred` only takes effect when Kimi's
7459
+ * experimental `tool-select` flag is on, and is captured when a server connects
7460
+ * (MoonshotAI/kimi-code#4210).
7230
7461
  *
7231
7462
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#mcp
7232
7463
  */
7233
7464
  const kimiCodeScopedMcpSchema = zod_mini.z.extend(toolScopedMcpSchema, {
7234
7465
  startupTimeoutMs: zod_mini.z.optional(zod_mini.z.number()),
7235
- toolTimeoutMs: zod_mini.z.optional(zod_mini.z.number())
7466
+ toolTimeoutMs: zod_mini.z.optional(zod_mini.z.number()),
7467
+ defaultDeferred: zod_mini.z.optional(zod_mini.z.boolean())
7236
7468
  });
7237
7469
  /**
7238
7470
  * Qwen Code's tool-scoped block also carries the server-level allow/deny lists
@@ -7272,6 +7504,7 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
7272
7504
  devin: zod_mini.z.optional(toolScopedMcpSchema),
7273
7505
  dsh: zod_mini.z.optional(toolScopedMcpSchema),
7274
7506
  factorydroid: zod_mini.z.optional(toolScopedMcpSchema),
7507
+ firebender: zod_mini.z.optional(toolScopedMcpSchema),
7275
7508
  gitlabduo: zod_mini.z.optional(toolScopedMcpSchema),
7276
7509
  goose: zod_mini.z.optional(toolScopedMcpSchema),
7277
7510
  grokcli: zod_mini.z.optional(toolScopedMcpSchema),
@@ -7296,6 +7529,7 @@ const RulesyncMcpFileSchema = zod_mini.z.looseObject({
7296
7529
  vibe: zod_mini.z.optional(toolScopedMcpSchema),
7297
7530
  warp: zod_mini.z.optional(toolScopedMcpSchema),
7298
7531
  warpcli: zod_mini.z.optional(toolScopedMcpSchema),
7532
+ xum: zod_mini.z.optional(toolScopedMcpSchema),
7299
7533
  zed: zod_mini.z.optional(toolScopedMcpSchema)
7300
7534
  });
7301
7535
  /**
@@ -7999,13 +8233,16 @@ const CopilotPermissionsOverrideSchema = zod_mini.z.looseObject({
7999
8233
  });
8000
8234
  /**
8001
8235
  * oh-my-pi's `tools.approvalMode` values: the tool tiers it auto-approves
8002
- * before prompting. `always-ask` auto-approves only read-only tools, `write`
8003
- * also workspace writes, and `yolo` (oh-my-pi's default) every tier.
8236
+ * before prompting. `always-ask` auto-approves only read-only tools,
8237
+ * `always-ask-cached` (Ask and remember) also prompts before file reads and
8238
+ * lets a prompt remember the granted file or folder, `write` also
8239
+ * auto-approves workspace writes, and `yolo` (oh-my-pi's default) every tier.
8004
8240
  *
8005
8241
  * @see https://github.com/can1357/oh-my-pi/blob/main/docs/approval-mode.md
8006
8242
  */
8007
8243
  const OMP_APPROVAL_MODES = [
8008
8244
  "always-ask",
8245
+ "always-ask-cached",
8009
8246
  "write",
8010
8247
  "yolo"
8011
8248
  ];
@@ -8026,10 +8263,13 @@ const KimiCodePermissionsOverrideSchema = zod_mini.z.looseObject({
8026
8263
  ])),
8027
8264
  /**
8028
8265
  * Kimi's `[permission] dangerous_command_guard` switch (Kimi Code 0.40.0,
8029
- * default `true`). `false` turns off the built-in dangerous-command policy
8030
- * (no confirmation for `rm -rf`, `shutdown` and the like).
8266
+ * default `true`). Since Kimi Code 2.3.0, `false` turns off every built-in
8267
+ * guard confirmation policy: dangerous commands (`rm -rf`, `shutdown` and the
8268
+ * like), sensitive-file access (`.env`, `id_rsa`, ...) and git control-path
8269
+ * access. Before 2.3.0 it only covered dangerous commands.
8031
8270
  *
8032
8271
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#permission
8272
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4250
8033
8273
  */
8034
8274
  dangerousCommandGuard: zod_mini.z.optional(zod_mini.z.boolean()),
8035
8275
  rules: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.looseObject({
@@ -8389,6 +8629,8 @@ const TabninePermissionsOverrideSchema = zod_mini.z.looseObject({
8389
8629
  * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
8390
8630
  * @example
8391
8631
  * { "mode": "deny" }
8632
+ * @example
8633
+ * { "tools": { "disabled": ["web_fetch"] }, "askOnExternalOrigin": true }
8392
8634
  */
8393
8635
  const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
8394
8636
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -8402,7 +8644,9 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
8402
8644
  "ask",
8403
8645
  "allow",
8404
8646
  "deny"
8405
- ]))
8647
+ ])),
8648
+ askOnExternalOrigin: zod_mini.z.optional(zod_mini.z.boolean()),
8649
+ tools: zod_mini.z.optional(zod_mini.z.strictObject({ disabled: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) }))
8406
8650
  });
8407
8651
  /**
8408
8652
  * Tool-scoped override block for Qoder. `outputStyle` is written to the
@@ -11216,6 +11460,15 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
11216
11460
  compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
11217
11461
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
11218
11462
  })),
11463
+ xum: zod_mini.z.optional(zod_mini.z.looseObject({
11464
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
11465
+ advertise: zod_mini.z.optional(zod_mini.z.boolean()),
11466
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
11467
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
11468
+ when_to_use: zod_mini.z.optional(zod_mini.z.string()),
11469
+ "when-to-use": zod_mini.z.optional(zod_mini.z.string()),
11470
+ paths: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
11471
+ })),
11219
11472
  zcode: zod_mini.z.optional(zod_mini.z.looseObject({
11220
11473
  when_to_use: zod_mini.z.optional(zod_mini.z.string()),
11221
11474
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -11494,7 +11747,7 @@ async function getLocalSkillDirNames(sourceTree) {
11494
11747
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
11495
11748
  * default that applies to every tool supporting the flag (claudecode, codebuddy, copilot,
11496
11749
  * copilotcli, crush, cursor, zed, pi, omp, qwencode, vibe, grokcli, factorydroid, dsh, reasonix,
11497
- * commandcode, lettacode, opencode, mimocode). Each tool's own section may override that
11750
+ * commandcode, lettacode, opencode, mimocode, xum). Each tool's own section may override that
11498
11751
  * default with a per-target value. A defined section value (including `false`)
11499
11752
  * always wins over the root default.
11500
11753
  *
@@ -11514,7 +11767,7 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
11514
11767
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
11515
11768
  * that applies to every tool supporting the flag (claudecode, codebuddy, copilot,
11516
11769
  * copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid, dsh, reasonix, commandcode,
11517
- * lettacode). Each tool's own section may override that default with a
11770
+ * lettacode, xum). Each tool's own section may override that default with a
11518
11771
  * per-target value. A defined section value (including `false`) always wins
11519
11772
  * over the root default.
11520
11773
  *
@@ -13091,6 +13344,35 @@ const CRUSH_CONFIG_DECLARATION = {
13091
13344
  }
13092
13345
  };
13093
13346
  /**
13347
+ * Firebender's `firebender.json`, shared by the project (`<project>/firebender.json`)
13348
+ * and personal (`~/.firebender/firebender.json`) spellings so a policy edit
13349
+ * lands on both. The file also carries `rules`, `commands`, `mcpEnvFile`,
13350
+ * `worktrees` and `useCursorRules` keys rulesync does not own, so every writer
13351
+ * refuses to read-modify-write a file it could not parse. Each feature owns
13352
+ * one whole top-level key: `ignore` (recomputed from `.rulesync/.aiignore`),
13353
+ * `mcpServers` (the complete server map) and `agents` (recomputed from the
13354
+ * existing list, with the entries for rulesync's own agent directory replaced).
13355
+ * https://docs.firebender.com/api-reference/syntax
13356
+ */
13357
+ const FIREBENDER_CONFIG_DECLARATION = {
13358
+ format: "json",
13359
+ invalidRootPolicy: "error",
13360
+ features: {
13361
+ ignore: {
13362
+ kind: "replace-owned-keys",
13363
+ ownedKeys: ["ignore"]
13364
+ },
13365
+ subagents: {
13366
+ kind: "replace-owned-keys",
13367
+ ownedKeys: ["agents"]
13368
+ },
13369
+ mcp: {
13370
+ kind: "replace-owned-keys",
13371
+ ownedKeys: ["mcpServers"]
13372
+ }
13373
+ }
13374
+ };
13375
+ /**
13094
13376
  * What the two Claude Code settings files have in common: both are plain JSON,
13095
13377
  * and both validate against the one published schema, which the gateway
13096
13378
  * points every file it writes at. `.claude/settings.local.json` is the same
@@ -13330,6 +13612,15 @@ const SHARED_CONFIG_OWNERSHIP = {
13330
13612
  ownedKeys: ["servers"]
13331
13613
  } }
13332
13614
  },
13615
+ ".xum/mcp.jsonc": {
13616
+ format: "jsonc",
13617
+ invalidRootPolicy: "error",
13618
+ jsoncParseErrors: "error",
13619
+ features: { mcp: {
13620
+ kind: "replace-owned-keys",
13621
+ ownedKeys: ["servers"]
13622
+ } }
13623
+ },
13333
13624
  ".qwen/settings.json": {
13334
13625
  format: "json",
13335
13626
  features: {
@@ -13417,6 +13708,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13417
13708
  ".zcode/cli/config.json": ZCODE_CONFIG_DECLARATION,
13418
13709
  "crush.json": CRUSH_CONFIG_DECLARATION,
13419
13710
  ".config/crush/crush.json": CRUSH_CONFIG_DECLARATION,
13711
+ "firebender.json": FIREBENDER_CONFIG_DECLARATION,
13712
+ ".firebender/firebender.json": FIREBENDER_CONFIG_DECLARATION,
13420
13713
  ".bob/settings.json": {
13421
13714
  format: "json",
13422
13715
  invalidRootPolicy: "error",
@@ -13789,7 +14082,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13789
14082
  ownedKeys: [
13790
14083
  "permissions",
13791
14084
  "sandbox",
13792
- "agent"
14085
+ "agent",
14086
+ "tools"
13793
14087
  ]
13794
14088
  }
13795
14089
  }
@@ -13810,7 +14104,8 @@ const SHARED_CONFIG_OWNERSHIP = {
13810
14104
  ownedKeys: [
13811
14105
  "permissions",
13812
14106
  "sandbox",
13813
- "agent"
14107
+ "agent",
14108
+ "tools"
13814
14109
  ]
13815
14110
  }
13816
14111
  }
@@ -20251,6 +20546,178 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
20251
20546
  }
20252
20547
  };
20253
20548
  //#endregion
20549
+ //#region src/constants/firebender-paths.ts
20550
+ /**
20551
+ * Firebender (the coding agent plugin for Android Studio and other JetBrains
20552
+ * IDEs) keeps project configuration under `.firebender/` plus a project-root
20553
+ * `firebender.json`, and user configuration under `~/.firebender/` (including
20554
+ * `~/.firebender/firebender.json`).
20555
+ *
20556
+ * @see https://docs.firebender.com/api-reference/syntax
20557
+ * @see https://docs.firebender.com/api-reference/rules
20558
+ * @see https://docs.firebender.com/api-reference/commands
20559
+ * @see https://docs.firebender.com/api-reference/agents
20560
+ * @see https://docs.firebender.com/api-reference/skills
20561
+ * @see https://docs.firebender.com/api-reference/agents-md
20562
+ * @see https://docs.firebender.com/multi-agent/hooks
20563
+ */
20564
+ const FIREBENDER_DIR = ".firebender";
20565
+ /**
20566
+ * `firebender.json`: at the project root for project scope, and under
20567
+ * `~/.firebender/` for the personal config. It is shared by the ignore
20568
+ * (`ignore`), MCP (`mcpServers`) and subagents (`agents`) features.
20569
+ */
20570
+ const FIREBENDER_CONFIG_FILE_NAME = "firebender.json";
20571
+ /** `.firebender/AGENTS.md`, the first file Firebender looks for in both scopes. */
20572
+ const FIREBENDER_AGENTS_MD_FILE_NAME = "AGENTS.md";
20573
+ (0, node_path.join)(FIREBENDER_DIR, "rules");
20574
+ /** Commands: `.firebender/commands/*.mdc` (project) and `~/.firebender/commands/*.mdc` (global). */
20575
+ const FIREBENDER_COMMANDS_DIR_PATH = (0, node_path.join)(FIREBENDER_DIR, "commands");
20576
+ /** Agents: Markdown files registered through the `agents` array of `firebender.json`. */
20577
+ const FIREBENDER_AGENTS_DIR_NAME = "agents";
20578
+ const FIREBENDER_AGENTS_DIR_PATH = (0, node_path.join)(FIREBENDER_DIR, FIREBENDER_AGENTS_DIR_NAME);
20579
+ /** Skills: `.firebender/skills/<name>/SKILL.md` (project) and `~/.firebender/skills/` (global). */
20580
+ const FIREBENDER_SKILLS_DIR_PATH = (0, node_path.join)(FIREBENDER_DIR, "skills");
20581
+ /** Hooks: `.firebender/hooks.json` (project) and `~/.firebender/hooks.json` (global). */
20582
+ const FIREBENDER_HOOKS_FILE_NAME = "hooks.json";
20583
+ /**
20584
+ * The directory holding `firebender.json` for a scope: the project root, or
20585
+ * `~/.firebender/` for the personal config.
20586
+ */
20587
+ function getFirebenderConfigDir({ global }) {
20588
+ return global ? FIREBENDER_DIR : ".";
20589
+ }
20590
+ //#endregion
20591
+ //#region src/features/commands/firebender-command.ts
20592
+ /**
20593
+ * Frontmatter of a Firebender command. Every field is optional: `name`
20594
+ * defaults to the file name, `model` is `default`, `quick` or a model ID, and
20595
+ * `mode` is one of `auto`, `read`, `write` or `composer`. Unknown keys are
20596
+ * preserved.
20597
+ *
20598
+ * @see https://docs.firebender.com/api-reference/commands
20599
+ */
20600
+ const FirebenderCommandFrontmatterSchema = zod_mini.z.looseObject({
20601
+ name: zod_mini.z.optional(zod_mini.z.string()),
20602
+ description: zod_mini.z.optional(zod_mini.z.string()),
20603
+ model: zod_mini.z.optional(zod_mini.z.string()),
20604
+ mode: zod_mini.z.optional(zod_mini.z.string())
20605
+ });
20606
+ const FIREBENDER_COMMAND_EXTENSION = ".mdc";
20607
+ /**
20608
+ * Command generator for Firebender: one `.mdc` file per command under
20609
+ * `.firebender/commands/` (project) and `~/.firebender/commands/` (global),
20610
+ * with optional `name` / `description` / `model` / `mode` frontmatter. The
20611
+ * canonical `description` is written as is; `name`, `model` and `mode` come
20612
+ * from the `firebender:` section of the rulesync command.
20613
+ *
20614
+ * @see https://docs.firebender.com/api-reference/commands
20615
+ */
20616
+ var FirebenderCommand = class FirebenderCommand extends ToolCommand {
20617
+ frontmatter;
20618
+ body;
20619
+ constructor({ frontmatter, body, ...rest }) {
20620
+ if (rest.validate) {
20621
+ const result = FirebenderCommandFrontmatterSchema.safeParse(frontmatter);
20622
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
20623
+ }
20624
+ super({
20625
+ ...rest,
20626
+ fileContent: stringifyFrontmatter(body, frontmatter, { avoidBlockScalars: true })
20627
+ });
20628
+ this.frontmatter = frontmatter;
20629
+ this.body = body;
20630
+ }
20631
+ static getSettablePaths(_options = {}) {
20632
+ return { relativeDirPath: FIREBENDER_COMMANDS_DIR_PATH };
20633
+ }
20634
+ getBody() {
20635
+ return this.body;
20636
+ }
20637
+ getFrontmatter() {
20638
+ return this.frontmatter;
20639
+ }
20640
+ toRulesyncCommand() {
20641
+ const { description, ...restFields } = this.frontmatter;
20642
+ const rulesyncFrontmatter = {
20643
+ targets: ["*"],
20644
+ description,
20645
+ ...Object.keys(restFields).length > 0 && { firebender: restFields }
20646
+ };
20647
+ return new RulesyncCommand({
20648
+ outputRoot: ".",
20649
+ frontmatter: rulesyncFrontmatter,
20650
+ body: this.body,
20651
+ relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath,
20652
+ relativeFilePath: this.relativeFilePath.replace(/\.mdc$/, ".md"),
20653
+ fileContent: stringifyFrontmatter(this.body, rulesyncFrontmatter),
20654
+ validate: true
20655
+ });
20656
+ }
20657
+ static fromRulesyncCommand({ outputRoot = process.cwd(), rulesyncCommand, validate = true, global = false }) {
20658
+ const rulesyncFrontmatter = rulesyncCommand.getFrontmatter();
20659
+ const firebenderFields = rulesyncFrontmatter.firebender ?? {};
20660
+ const frontmatter = {
20661
+ ...rulesyncFrontmatter.description && { description: rulesyncFrontmatter.description },
20662
+ ...firebenderFields
20663
+ };
20664
+ return new FirebenderCommand({
20665
+ outputRoot,
20666
+ frontmatter,
20667
+ body: rulesyncCommand.getBody(),
20668
+ relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
20669
+ relativeFilePath: rulesyncCommand.getRelativeFilePath().replace(/\.md$/, FIREBENDER_COMMAND_EXTENSION),
20670
+ validate
20671
+ });
20672
+ }
20673
+ validate() {
20674
+ if (!this.frontmatter) return {
20675
+ success: true,
20676
+ error: null
20677
+ };
20678
+ const result = FirebenderCommandFrontmatterSchema.safeParse(this.frontmatter);
20679
+ if (result.success) return {
20680
+ success: true,
20681
+ error: null
20682
+ };
20683
+ return {
20684
+ success: false,
20685
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
20686
+ };
20687
+ }
20688
+ static isTargetedByRulesyncCommand(rulesyncCommand) {
20689
+ return this.isTargetedByRulesyncCommandDefault({
20690
+ rulesyncCommand,
20691
+ toolTarget: "firebender"
20692
+ });
20693
+ }
20694
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
20695
+ const paths = this.getSettablePaths({ global });
20696
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
20697
+ const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
20698
+ const result = FirebenderCommandFrontmatterSchema.safeParse(frontmatter);
20699
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
20700
+ return new FirebenderCommand({
20701
+ outputRoot,
20702
+ relativeDirPath: paths.relativeDirPath,
20703
+ relativeFilePath,
20704
+ frontmatter: result.data,
20705
+ body: body.trim(),
20706
+ validate
20707
+ });
20708
+ }
20709
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
20710
+ return new FirebenderCommand({
20711
+ outputRoot,
20712
+ relativeDirPath,
20713
+ relativeFilePath,
20714
+ frontmatter: {},
20715
+ body: "",
20716
+ validate: false
20717
+ });
20718
+ }
20719
+ };
20720
+ //#endregion
20254
20721
  //#region src/features/commands/gitlabduo-command.ts
20255
20722
  /**
20256
20723
  * GitLab Duo CLI custom slash commands are Markdown files named after the
@@ -20843,6 +21310,60 @@ var GrokcliCommand = class GrokcliCommand extends ToolCommand {
20843
21310
  }
20844
21311
  };
20845
21312
  //#endregion
21313
+ //#region src/features/shared/hermes-plugin-commands.ts
21314
+ /**
21315
+ * Normalize a name into a Hermes Agent plugin slash command name.
21316
+ *
21317
+ * `ctx.register_command()` lowercases the name, and Hermes' messaging gateway
21318
+ * looks plugin commands up with every `_` turned into `-`
21319
+ * (`gateway/run_inbound_hooks.py`), so only a lowercase, hyphenated name is
21320
+ * reachable from every surface.
21321
+ */
21322
+ function hermesSlashName(value) {
21323
+ return value.toLowerCase().replace(/[ _]/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
21324
+ }
21325
+ /**
21326
+ * Python helpers shared by the generated Hermes command plugins.
21327
+ *
21328
+ * A plugin slash command runs outside any agent turn, so it cannot call
21329
+ * `delegate_task` (which requires a parent agent). Instead the handler queues
21330
+ * its prompt as a user message with `ctx.inject_message()`, passing the calling
21331
+ * session's key: a `session_key` handler keyword on newer Hermes, or
21332
+ * `HERMES_SESSION_KEY` from the session context the gateway binds around the
21333
+ * handler on older releases.
21334
+ */
21335
+ function getHermesQueueUserMessagePython({ pluginName }) {
21336
+ return `def _session_key(kwargs):
21337
+ session_key = kwargs.get("session_key")
21338
+ if session_key:
21339
+ return session_key
21340
+ try:
21341
+ from gateway.session_context import get_session_env
21342
+ except Exception:
21343
+ return None
21344
+ return get_session_env("HERMES_SESSION_KEY") or None
21345
+
21346
+
21347
+ def _queue_user_message(ctx, message, kwargs, label):
21348
+ """Queue *message* as the session's next user turn; return an error reply, or None."""
21349
+ session_key = _session_key(kwargs)
21350
+ try:
21351
+ if session_key:
21352
+ queued = ctx.inject_message(message, role="user", session_key=session_key)
21353
+ else:
21354
+ queued = ctx.inject_message(message, role="user")
21355
+ except Exception as error:
21356
+ return f"Could not queue {label}: {error}"
21357
+ if not queued:
21358
+ return (
21359
+ f"Hermes refused to queue {label}: there is no active session, or this plugin "
21360
+ "may not inject messages. In gateway sessions, set "
21361
+ "plugins.entries.${pluginName}.allow_gateway_injection: true in config.yaml."
21362
+ )
21363
+ return None
21364
+ `;
21365
+ }
21366
+ //#endregion
20846
21367
  //#region src/features/skills/agentsskills-skill.ts
20847
21368
  const AgentsSkillsSkillFrontmatterSchema = zod_mini.z.looseObject({
20848
21369
  name: zod_mini.z.string(),
@@ -21325,9 +21846,6 @@ const HermesCommandSpecSchema = zod_mini.z.object({
21325
21846
  description: zod_mini.z.string(),
21326
21847
  prompt: zod_mini.z.string()
21327
21848
  });
21328
- function hermesSlashName(value) {
21329
- return value.toLowerCase().replace(/[ _]/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-").replace(/^-|-$/g, "");
21330
- }
21331
21849
  function getPluginManifestContent$2() {
21332
21850
  return [
21333
21851
  "name: rulesync-commands",
@@ -21361,6 +21879,8 @@ def _load_commands():
21361
21879
  return commands
21362
21880
 
21363
21881
 
21882
+ ${getHermesQueueUserMessagePython({ pluginName: "rulesync-commands" })}
21883
+
21364
21884
  def _register_command(ctx, command):
21365
21885
  slug = command.get("slug")
21366
21886
  if not slug:
@@ -21370,31 +21890,22 @@ def _register_command(ctx, command):
21370
21890
  prompt = command.get("prompt") or ""
21371
21891
 
21372
21892
  def handler(args=None, **kwargs):
21373
- del kwargs
21374
21893
  raw_args = ""
21375
21894
  if isinstance(args, dict):
21376
21895
  raw_args = args.get("args") or args.get("context") or args.get("prompt") or ""
21377
21896
  elif args is not None:
21378
21897
  raw_args = str(args)
21379
21898
 
21380
- context_parts = [prompt] if prompt else []
21899
+ parts = [prompt] if prompt else []
21381
21900
  if raw_args:
21382
- context_parts.append(f"User arguments:\\n{raw_args}")
21901
+ parts.append(f"User arguments:\\n{raw_args}")
21383
21902
 
21384
- # delegate_task takes no model-facing "toolsets" argument: the child
21385
- # inherits the parent's enabled toolsets. "tasks" is the only shape its
21386
- # schema advertises; a top-level "goal" is an unadvertised legacy path.
21387
- return ctx.dispatch_tool(
21388
- "delegate_task",
21389
- {
21390
- "tasks": [
21391
- {
21392
- "goal": description,
21393
- "context": "\\n\\n".join(context_parts),
21394
- },
21395
- ],
21396
- },
21397
- )
21903
+ # A slash command runs outside any agent turn, so calling delegate_task
21904
+ # here fails with "delegate_task requires a parent agent context".
21905
+ # Queue the command prompt as the session's next user message instead.
21906
+ label = f"the /{slug} command"
21907
+ error = _queue_user_message(ctx, "\\n\\n".join(parts) or description, kwargs, label)
21908
+ return error or f"Queued {label}."
21398
21909
 
21399
21910
  ctx.register_command(slug, handler, description)
21400
21911
 
@@ -23719,6 +24230,7 @@ var QwencodeCommand = class QwencodeCommand extends ToolCommand {
23719
24230
  const REASONIX_PROJECT_MCP_FILE_NAME = "reasonix.toml";
23720
24231
  const REASONIX_GLOBAL_DIR = ".reasonix";
23721
24232
  const REASONIX_GLOBAL_MCP_FILE_NAME = "config.toml";
24233
+ const REASONIX_NESTED_PROJECT_CONFIG_RELATIVE_FILE_PATH = (0, node_path.join)(".reasonix", REASONIX_PROJECT_MCP_FILE_NAME);
23722
24234
  const REASONIX_PROJECT_PERMISSIONS_FILE_NAME = REASONIX_PROJECT_MCP_FILE_NAME;
23723
24235
  const REASONIX_GLOBAL_PERMISSIONS_FILE_NAME = REASONIX_GLOBAL_MCP_FILE_NAME;
23724
24236
  const REASONIX_DIR = REASONIX_GLOBAL_DIR;
@@ -25266,6 +25778,16 @@ const toolCommandFactories = /* @__PURE__ */ new Map([
25266
25778
  supportsSubdirectory: false
25267
25779
  }
25268
25780
  }],
25781
+ ["firebender", {
25782
+ class: FirebenderCommand,
25783
+ meta: {
25784
+ extension: "mdc",
25785
+ supportsProject: true,
25786
+ supportsGlobal: true,
25787
+ isSimulated: false,
25788
+ supportsSubdirectory: false
25789
+ }
25790
+ }],
25269
25791
  ["goose", {
25270
25792
  class: GooseCommand,
25271
25793
  meta: {
@@ -28056,25 +28578,65 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
28056
28578
  //#endregion
28057
28579
  //#region src/features/hooks/antigravity-hooks.ts
28058
28580
  /**
28059
- * Antigravity uses a Claude-Code-like `hooks.json` (matcher shape). The
28060
- * `PreToolUse`/`PostToolUse` events use the matcher-based array shape, while
28061
- * `PreInvocation`/`PostInvocation`/`Stop` are matcher-less handler lists.
28062
- * `projectDirVar` is empty because Antigravity does not document a
28063
- * project-directory variable for commands.
28581
+ * Canonical events whose Antigravity counterparts (`SessionStart`,
28582
+ * `PreInvocation`, `PostInvocation`, `Stop`) take no matcher.
28583
+ */
28584
+ const ANTIGRAVITY_NO_MATCHER_EVENTS = /* @__PURE__ */ new Set([
28585
+ "sessionStart",
28586
+ "preModelInvocation",
28587
+ "postModelInvocation",
28588
+ "stop"
28589
+ ]);
28590
+ /**
28591
+ * Antigravity event names whose value is a flat list of handlers
28592
+ * (`"Stop": [ { "type": "command", "command": "..." } ]`) rather than a list
28593
+ * of `{ "matcher", "hooks": [...] }` groups. Antigravity's loader rejects a
28594
+ * wrapped group under these events ("command hook must specify 'command'")
28595
+ * and then skips the whole `hooks.json`.
28596
+ * https://antigravity.google/docs/hooks
28597
+ */
28598
+ const ANTIGRAVITY_FLAT_HANDLER_EVENTS = new Set([...ANTIGRAVITY_NO_MATCHER_EVENTS].map((event) => lookupOwn({
28599
+ record: CANONICAL_TO_ANTIGRAVITY_EVENT_NAMES,
28600
+ key: event
28601
+ }) ?? event));
28602
+ /**
28603
+ * Antigravity uses a Claude-Code-like `hooks.json`. The `PreToolUse` /
28604
+ * `PostToolUse` events use the matcher-based group shape, while `SessionStart`,
28605
+ * `PreInvocation`, `PostInvocation` and `Stop` are flat handler lists (see
28606
+ * {@link toAntigravityEventMap}). `projectDirVar` is empty because Antigravity
28607
+ * does not document a project-directory variable for commands.
28064
28608
  */
28065
28609
  const ANTIGRAVITY_CONVERTER_CONFIG = {
28066
28610
  supportedEvents: ANTIGRAVITY_HOOK_EVENTS,
28067
28611
  canonicalToToolEventNames: CANONICAL_TO_ANTIGRAVITY_EVENT_NAMES,
28068
28612
  toolToCanonicalEventNames: ANTIGRAVITY_TO_CANONICAL_EVENT_NAMES,
28069
28613
  projectDirVar: "",
28070
- noMatcherEvents: /* @__PURE__ */ new Set([
28071
- "preModelInvocation",
28072
- "postModelInvocation",
28073
- "stop"
28074
- ]),
28614
+ noMatcherEvents: ANTIGRAVITY_NO_MATCHER_EVENTS,
28075
28615
  supportedHookTypes: /* @__PURE__ */ new Set(["command"])
28076
28616
  };
28077
28617
  /**
28618
+ * Turn the shared converter's matcher-group output into Antigravity's shape:
28619
+ * the matcher-less events get their handlers directly under the event key.
28620
+ */
28621
+ function toAntigravityEventMap(eventMap) {
28622
+ return Object.fromEntries(Object.entries(eventMap).map(([event, entries]) => {
28623
+ if (!ANTIGRAVITY_FLAT_HANDLER_EVENTS.has(event)) return [event, entries];
28624
+ return [event, entries.flatMap((entry) => isPlainObject$1(entry) && Array.isArray(entry.hooks) ? entry.hooks : [])];
28625
+ }));
28626
+ }
28627
+ /**
28628
+ * Reverse of {@link toAntigravityEventMap} for import: wrap each flat handler
28629
+ * of a matcher-less event in a `{ hooks: [...] }` group so the shared converter
28630
+ * can read it. An entry that is already a group (`{ hooks: [...] }`, as older
28631
+ * rulesync versions wrote) is kept as is.
28632
+ */
28633
+ function toMatcherGroupEventMap(eventMap) {
28634
+ return Object.fromEntries(Object.entries(eventMap).map(([event, entries]) => {
28635
+ if (!ANTIGRAVITY_FLAT_HANDLER_EVENTS.has(event) || !Array.isArray(entries)) return [event, entries];
28636
+ return [event, entries.map((entry) => isPlainObject$1(entry) && !("hooks" in entry) ? { hooks: [entry] } : entry)];
28637
+ }));
28638
+ }
28639
+ /**
28078
28640
  * Antigravity's `hooks.json` is keyed by a named hook whose value holds the
28079
28641
  * per-event map (plus an optional `enabled` flag). rulesync writes a single
28080
28642
  * generated hook under this stable name.
@@ -28111,8 +28673,8 @@ function flattenAntigravityHooks(parsed) {
28111
28673
  * Hooks generator for Google Antigravity (both the IDE and the CLI).
28112
28674
  *
28113
28675
  * Antigravity writes a dedicated `hooks.json` keyed by a named hook whose
28114
- * value holds the Claude-Code-style event → matcher-entry map
28115
- * (`{ "<name>": { "<Event>": [...] } }`). Project and global modes share the
28676
+ * value holds the event map (`{ "<name>": { "<Event>": [...] } }`): matcher
28677
+ * groups for the tool events, flat handler lists for the others. Project and global modes share the
28116
28678
  * same shape; only the location differs (`.agents/hooks.json` vs
28117
28679
  * `~/.gemini/config/hooks.json`).
28118
28680
  *
@@ -28156,12 +28718,12 @@ var AntigravityHooks = class extends ToolHooks {
28156
28718
  static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
28157
28719
  const paths = this.getSettablePaths({ global });
28158
28720
  const config = rulesyncHooks.getJson();
28159
- const eventMap = canonicalToToolHooks({
28721
+ const eventMap = toAntigravityEventMap(canonicalToToolHooks({
28160
28722
  config,
28161
28723
  toolOverrideHooks: config[this.getOverrideKey()]?.hooks,
28162
28724
  converterConfig: ANTIGRAVITY_CONVERTER_CONFIG,
28163
28725
  logger
28164
- });
28726
+ }));
28165
28727
  const antigravityHooks = Object.keys(eventMap).length > 0 ? { [ANTIGRAVITY_HOOK_NAME]: eventMap } : {};
28166
28728
  const fileContent = JSON.stringify(antigravityHooks, null, 2);
28167
28729
  return new this({
@@ -28180,7 +28742,7 @@ var AntigravityHooks = class extends ToolHooks {
28180
28742
  throw new Error(`Failed to parse Antigravity hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28181
28743
  }
28182
28744
  const hooks = toolHooksToCanonical({
28183
- hooks: flattenAntigravityHooks(parsed),
28745
+ hooks: toMatcherGroupEventMap(flattenAntigravityHooks(parsed)),
28184
28746
  converterConfig: ANTIGRAVITY_CONVERTER_CONFIG,
28185
28747
  logger
28186
28748
  });
@@ -30644,11 +31206,17 @@ var ContinueHooks = class extends SettingsJsonHooks {
30644
31206
  * entry that uses them instead of a shell field is not mistaken for one
30645
31207
  * without a command.
30646
31208
  *
31209
+ * An optional `matcher` regex is honored on the events in
31210
+ * {@link COPILOT_MATCHER_HOOK_EVENTS}. The VS Code per-OS override fields come
31211
+ * from the legacy Local hooks reference and are kept for existing files.
31212
+ *
30647
31213
  * @see https://docs.github.com/en/copilot/reference/hooks-reference
31214
+ * @see https://code.visualstudio.com/docs/agent-customization/hooks
30648
31215
  * @see https://code.visualstudio.com/docs/agents/reference/hooks-reference
30649
31216
  */
30650
31217
  const CopilotHookEntrySchema = zod_mini.z.looseObject({
30651
31218
  type: zod_mini.z.string(),
31219
+ matcher: zod_mini.z.optional(zod_mini.z.string()),
30652
31220
  bash: zod_mini.z.optional(zod_mini.z.string()),
30653
31221
  powershell: zod_mini.z.optional(zod_mini.z.string()),
30654
31222
  command: zod_mini.z.optional(zod_mini.z.string()),
@@ -30682,8 +31250,10 @@ function canonicalToCopilotHooks(config) {
30682
31250
  ...sharedConfigHooks,
30683
31251
  ...config.copilot?.hooks
30684
31252
  };
31253
+ const matcherEvents = new Set(COPILOT_MATCHER_HOOK_EVENTS);
30685
31254
  const copilot = {};
30686
31255
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
31256
+ const matcherSupported = matcherEvents.has(eventName);
30687
31257
  const copilotEventName = lookupOwn({
30688
31258
  record: CANONICAL_TO_COPILOT_EVENT_NAMES,
30689
31259
  key: eventName
@@ -30691,7 +31261,7 @@ function canonicalToCopilotHooks(config) {
30691
31261
  const entries = [];
30692
31262
  for (const def of definitions) {
30693
31263
  const hookType = def.type ?? "command";
30694
- if (def.matcher) continue;
31264
+ if (def.matcher && !matcherSupported) continue;
30695
31265
  if (hookType !== "command") continue;
30696
31266
  const command = def.command;
30697
31267
  const timeout = def.timeout;
@@ -30699,6 +31269,7 @@ function canonicalToCopilotHooks(config) {
30699
31269
  const rest = Object.fromEntries(Object.entries(def).filter(([k]) => !canonicalSchemaKeys.includes(k)));
30700
31270
  entries.push({
30701
31271
  type: hookType,
31272
+ ...def.matcher && { matcher: def.matcher },
30702
31273
  ...command !== void 0 && command !== null && { [commandField]: command },
30703
31274
  ...def.env !== void 0 && { env: def.env },
30704
31275
  ...timeout !== void 0 && timeout !== null && { timeoutSec: timeout },
@@ -30793,6 +31364,7 @@ function copilotHooksToCanonical(copilotHooks, logger) {
30793
31364
  if (command === void 0 && hasOsOverride(passthrough)) logger?.warn(`Copilot hook on '${copilotEventName}' has only VS Code per-OS overrides (windows/linux/osx) and no portable command; it is preserved for copilot, but other targets will generate it without a command until a \`command\` is added.`);
30794
31365
  defs.push({
30795
31366
  type: "command",
31367
+ ...entry.matcher !== void 0 && entry.matcher !== "" && { matcher: entry.matcher },
30796
31368
  ...command !== void 0 && { command },
30797
31369
  ...shell !== void 0 && { shell },
30798
31370
  ...timeout !== void 0 && { timeout },
@@ -30936,21 +31508,13 @@ var CopilotHooks = class CopilotHooks extends ToolHooks {
30936
31508
  */
30937
31509
  /**
30938
31510
  * Canonical events whose Copilot CLI hook entries honor an optional `matcher`
30939
- * field (a regex compiled as `^(?:PATTERN)$`, tested against `toolName`).
30940
- *
30941
- * Confirmed by the upstream changelog: v1.0.36 (2026-04-24, `preToolUse.matcher`
30942
- * honored) and v1.0.63 (2026-06-15, "PostToolUse hook matchers (e.g.
30943
- * `Edit|Write`) are now honored instead of silently dropped").
30944
- * @see https://docs.github.com/en/copilot/reference/hooks-reference
31511
+ * field (a regex compiled as `^(?:PATTERN)$`). The hooks reference filters each
31512
+ * on a different field: the tool name for `preToolUse` / `postToolUse` /
31513
+ * `permissionRequest`, `notification_type`, the `preCompact` trigger
31514
+ * (`manual` | `auto`) and the `subagentStart` agent name.
31515
+ * See {@link COPILOTCLI_MATCHER_HOOK_EVENTS}.
30945
31516
  */
30946
- const COPILOTCLI_MATCHER_EVENTS = /* @__PURE__ */ new Set([
30947
- "preToolUse",
30948
- "postToolUse",
30949
- "notification",
30950
- "permissionRequest",
30951
- "preCompact",
30952
- "subagentStart"
30953
- ]);
31517
+ const COPILOTCLI_MATCHER_EVENTS = new Set(COPILOTCLI_MATCHER_HOOK_EVENTS);
30954
31518
  /** Human-readable list of the matcher-aware events, for the drop warning. */
30955
31519
  const COPILOTCLI_MATCHER_EVENTS_LABEL = [...COPILOTCLI_MATCHER_EVENTS].join("/");
30956
31520
  const CopilotCliHookEntrySchema = zod_mini.z.looseObject({
@@ -32549,6 +33113,116 @@ var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
32549
33113
  }
32550
33114
  };
32551
33115
  //#endregion
33116
+ //#region src/features/hooks/firebender-hooks.ts
33117
+ /**
33118
+ * Hooks generator for Firebender.
33119
+ *
33120
+ * Firebender reads `.firebender/hooks.json` (project) and
33121
+ * `~/.firebender/hooks.json` (global). The file follows Cursor's layout — a
33122
+ * `version` plus a `hooks` map from camelCase event names to lists of
33123
+ * `{ command, timeout?, loop_limit?, matcher? }` entries — and every event is
33124
+ * spelled like its canonical counterpart. The per-hook `type` accepts
33125
+ * `"command" | "prompt"`, but the docs never say what a prompt hook does, so
33126
+ * only command hooks are generated (the HooksProcessor warns about the rest).
33127
+ *
33128
+ * @see https://docs.firebender.com/multi-agent/hooks
33129
+ */
33130
+ var FirebenderHooks = class FirebenderHooks extends ToolHooks {
33131
+ constructor(params) {
33132
+ super({
33133
+ ...params,
33134
+ fileContent: params.fileContent ?? "{}"
33135
+ });
33136
+ }
33137
+ static getSettablePaths(_options = {}) {
33138
+ return {
33139
+ relativeDirPath: FIREBENDER_DIR,
33140
+ relativeFilePath: FIREBENDER_HOOKS_FILE_NAME
33141
+ };
33142
+ }
33143
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
33144
+ const paths = FirebenderHooks.getSettablePaths({ global });
33145
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath));
33146
+ return new FirebenderHooks({
33147
+ outputRoot,
33148
+ relativeDirPath: paths.relativeDirPath,
33149
+ relativeFilePath: paths.relativeFilePath,
33150
+ fileContent,
33151
+ validate
33152
+ });
33153
+ }
33154
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
33155
+ const config = rulesyncHooks.getJson();
33156
+ const supportedEvents = new Set(FIREBENDER_HOOK_EVENTS);
33157
+ const sharedHooks = {};
33158
+ for (const [event, defs] of Object.entries(config.hooks)) if (supportedEvents.has(event)) sharedHooks[event] = defs;
33159
+ const mergedHooks = {
33160
+ ...sharedHooks,
33161
+ ...config.firebender?.hooks
33162
+ };
33163
+ const firebenderHooks = {};
33164
+ for (const [eventName, defs] of Object.entries(mergedHooks)) {
33165
+ const mappedDefs = defs.filter((def) => (def.type ?? "command") === "command").filter((def) => typeof def.command === "string" && def.command.length > 0).map((def) => ({
33166
+ command: def.command,
33167
+ ...def.timeout !== void 0 && def.timeout !== null && { timeout: def.timeout },
33168
+ ...def.loop_limit !== void 0 && { loop_limit: def.loop_limit },
33169
+ ...def.matcher !== void 0 && def.matcher !== null && { matcher: def.matcher }
33170
+ }));
33171
+ if (mappedDefs.length > 0) firebenderHooks[eventName] = mappedDefs;
33172
+ }
33173
+ const paths = FirebenderHooks.getSettablePaths({ global });
33174
+ const merged = mergeGeneratedHookLists({
33175
+ existingContent: await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "",
33176
+ generatedHooks: firebenderHooks,
33177
+ shape: "flat",
33178
+ preserveUnowned,
33179
+ previouslyOwned: preserveUnowned ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
33180
+ logger
33181
+ });
33182
+ const fileContent = JSON.stringify({
33183
+ version: config.version ?? 1,
33184
+ hooks: merged.hooks
33185
+ }, null, 2);
33186
+ return new FirebenderHooks({
33187
+ outputRoot,
33188
+ relativeDirPath: paths.relativeDirPath,
33189
+ relativeFilePath: paths.relativeFilePath,
33190
+ fileContent,
33191
+ ownedHookRefs: preserveUnowned ? merged.owned : void 0,
33192
+ validate
33193
+ });
33194
+ }
33195
+ static supportsPreserveUnowned() {
33196
+ return true;
33197
+ }
33198
+ static async getAuxiliaryFiles({ toolHooks } = {}) {
33199
+ return toolHooks instanceof FirebenderHooks ? toolHooks.getOwnershipLockFiles() : [];
33200
+ }
33201
+ toRulesyncHooks() {
33202
+ const parsed = JSON.parse(this.getFileContent());
33203
+ return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
33204
+ hooks: parsed.hooks ?? {},
33205
+ overrideKey: "firebender",
33206
+ version: parsed.version ?? 1
33207
+ }), null, 2) });
33208
+ }
33209
+ validate() {
33210
+ return {
33211
+ success: true,
33212
+ error: null
33213
+ };
33214
+ }
33215
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
33216
+ return new FirebenderHooks({
33217
+ outputRoot,
33218
+ relativeDirPath,
33219
+ relativeFilePath,
33220
+ fileContent: "{}",
33221
+ validate: false
33222
+ });
33223
+ }
33224
+ };
33225
+ //#endregion
32552
33226
  //#region src/features/hooks/gitlabduo-hooks.ts
32553
33227
  const GITLABDUO_CONVERTER_CONFIG = {
32554
33228
  supportedEvents: GITLABDUO_HOOK_EVENTS,
@@ -32976,7 +33650,7 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
32976
33650
  for (const [nativeEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
32977
33651
  if (HERMESAGENT_CANONICAL_EVENTS.has(nativeEvent)) continue;
32978
33652
  if (HERMESAGENT_SHELL_UNSUPPORTED_EVENTS.has(nativeEvent)) logger?.warn(`Hermes hook event "${nativeEvent}" is Python-plugin-only; Hermes Agent refuses it for shell hooks, so the generated entry will never run.`);
32979
- else if (!HERMESAGENT_NATIVE_EVENTS.has(nativeEvent)) logger?.warn(`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.21.6; preserving it for forward compatibility.`);
33653
+ else if (!HERMESAGENT_NATIVE_EVENTS.has(nativeEvent)) logger?.warn(`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.22.3; preserving it for forward compatibility.`);
32980
33654
  setHermesHookEntries({
32981
33655
  result,
32982
33656
  event: nativeEvent,
@@ -36959,6 +37633,17 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
36959
37633
  supportedHookTypes: ["command", "prompt"],
36960
37634
  supportsMatcher: true
36961
37635
  }],
37636
+ ["firebender", {
37637
+ class: FirebenderHooks,
37638
+ meta: {
37639
+ supportsProject: true,
37640
+ supportsGlobal: true,
37641
+ supportsImport: true
37642
+ },
37643
+ supportedEvents: FIREBENDER_HOOK_EVENTS,
37644
+ supportedHookTypes: ["command"],
37645
+ supportsMatcher: true
37646
+ }],
36962
37647
  ["claudecode", {
36963
37648
  class: ClaudecodeHooks,
36964
37649
  meta: {
@@ -37055,7 +37740,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
37055
37740
  },
37056
37741
  supportedEvents: COPILOT_HOOK_EVENTS,
37057
37742
  supportedHookTypes: ["command"],
37058
- supportsMatcher: false
37743
+ supportsMatcher: true,
37744
+ matcherEvents: COPILOT_MATCHER_HOOK_EVENTS
37059
37745
  }],
37060
37746
  ["copilotcli", {
37061
37747
  class: CopilotcliHooks,
@@ -37070,7 +37756,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
37070
37756
  "prompt",
37071
37757
  "http"
37072
37758
  ],
37073
- supportsMatcher: false
37759
+ supportsMatcher: true
37074
37760
  }],
37075
37761
  ["kilo", {
37076
37762
  class: KiloHooks,
@@ -37830,6 +38516,67 @@ var AiassistantIgnore = class AiassistantIgnore extends ToolIgnore {
37830
38516
  }
37831
38517
  };
37832
38518
  //#endregion
38519
+ //#region src/constants/androidstudio-paths.ts
38520
+ /**
38521
+ * Gemini in Android Studio configuration-layout conventions.
38522
+ *
38523
+ * Only the Android Studio-specific files are owned by the `androidstudio`
38524
+ * target. The shared `AGENTS.md` and `.agents/skills/` surfaces Gemini also
38525
+ * reads belong to the `agentsmd` and `agentsskills` targets.
38526
+ */
38527
+ const ANDROIDSTUDIO_IGNORE_FILE_NAME = ".aiexclude";
38528
+ const ANDROIDSTUDIO_SKILLS_DIR_PATH = (0, node_path.join)(".android-studio", "skills");
38529
+ //#endregion
38530
+ //#region src/features/ignore/androidstudio-ignore.ts
38531
+ /**
38532
+ * Ignore generator for Gemini in Android Studio.
38533
+ *
38534
+ * Gemini reads `.aiexclude` files (gitignore syntax) anywhere in the project;
38535
+ * each one applies to the files at or below its directory and blocks both the
38536
+ * chat and the editor AI features such as code completion. Rulesync writes the
38537
+ * project-root file only. There is no user-level variant.
38538
+ *
38539
+ * @see https://developer.android.com/studio/gemini/aiexclude
38540
+ */
38541
+ var AndroidstudioIgnore = class AndroidstudioIgnore extends ToolIgnore {
38542
+ static getSettablePaths() {
38543
+ return {
38544
+ relativeDirPath: ".",
38545
+ relativeFilePath: ANDROIDSTUDIO_IGNORE_FILE_NAME
38546
+ };
38547
+ }
38548
+ toRulesyncIgnore() {
38549
+ return this.toRulesyncIgnoreDefault();
38550
+ }
38551
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
38552
+ return new AndroidstudioIgnore({
38553
+ outputRoot,
38554
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
38555
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
38556
+ fileContent: rulesyncIgnore.getFileContent()
38557
+ });
38558
+ }
38559
+ static async fromFile({ outputRoot = process.cwd(), validate = true }) {
38560
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath));
38561
+ return new AndroidstudioIgnore({
38562
+ outputRoot,
38563
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
38564
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
38565
+ fileContent,
38566
+ validate
38567
+ });
38568
+ }
38569
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
38570
+ return new AndroidstudioIgnore({
38571
+ outputRoot,
38572
+ relativeDirPath,
38573
+ relativeFilePath,
38574
+ fileContent: "",
38575
+ validate: false
38576
+ });
38577
+ }
38578
+ };
38579
+ //#endregion
37833
38580
  //#region src/features/ignore/antigravity-cli-ignore.ts
37834
38581
  /**
37835
38582
  * Antigravity CLI is the successor to Gemini CLI and is built on the same
@@ -38491,6 +39238,95 @@ var DevinIgnore = class DevinIgnore extends ToolIgnore {
38491
39238
  }
38492
39239
  };
38493
39240
  //#endregion
39241
+ //#region src/features/ignore/firebender-ignore.ts
39242
+ function readIgnorePatterns(fileContent) {
39243
+ const ignore = parseSharedConfig({
39244
+ format: "json",
39245
+ fileContent
39246
+ }).ignore;
39247
+ if (!Array.isArray(ignore)) return [];
39248
+ return ignore.filter((pattern) => typeof pattern === "string");
39249
+ }
39250
+ /**
39251
+ * Ignore generator for Firebender.
39252
+ *
39253
+ * Firebender reads gitignore-style patterns from the `ignore` array of
39254
+ * `firebender.json` (project root) and `~/.firebender/firebender.json`
39255
+ * (personal); the two lists are appended together. The file is shared with the
39256
+ * MCP (`mcpServers`) and subagents (`agents`) features and carries settings
39257
+ * rulesync does not own, so only the `ignore` key is written and the file is
39258
+ * never deleted.
39259
+ *
39260
+ * @see https://docs.firebender.com/api-reference/syntax
39261
+ */
39262
+ var FirebenderIgnore = class FirebenderIgnore extends ToolIgnore {
39263
+ constructor(params) {
39264
+ super(params);
39265
+ this.patterns = readIgnorePatterns(this.fileContent || "{}");
39266
+ }
39267
+ static getSettablePaths({ global = false } = {}) {
39268
+ return {
39269
+ relativeDirPath: getFirebenderConfigDir({ global }),
39270
+ relativeFilePath: FIREBENDER_CONFIG_FILE_NAME
39271
+ };
39272
+ }
39273
+ /** `firebender.json` is a user-managed config file, so rulesync never deletes it. */
39274
+ isDeletable() {
39275
+ return false;
39276
+ }
39277
+ toRulesyncIgnore() {
39278
+ return new RulesyncIgnore({
39279
+ outputRoot: ".",
39280
+ relativeDirPath: RulesyncIgnore.getSettablePaths().recommended.relativeDirPath,
39281
+ relativeFilePath: RulesyncIgnore.getSettablePaths().recommended.relativeFilePath,
39282
+ fileContent: this.patterns.filter((pattern) => pattern.length > 0).join("\n")
39283
+ });
39284
+ }
39285
+ static async fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, global = false }) {
39286
+ const patterns = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
39287
+ const paths = this.getSettablePaths({ global });
39288
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39289
+ const existingContent = await readFileContentOrNull(filePath) ?? "{}";
39290
+ const managedPatterns = [...new Set(patterns)];
39291
+ return new FirebenderIgnore({
39292
+ outputRoot,
39293
+ relativeDirPath: paths.relativeDirPath,
39294
+ relativeFilePath: paths.relativeFilePath,
39295
+ fileContent: applySharedConfigPatch({
39296
+ fileKey: sharedConfigFileKey(paths),
39297
+ feature: "ignore",
39298
+ existingContent,
39299
+ patch: { ignore: managedPatterns.length > 0 ? managedPatterns : void 0 },
39300
+ filePath
39301
+ }),
39302
+ validate: true,
39303
+ global
39304
+ });
39305
+ }
39306
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
39307
+ const paths = this.getSettablePaths({ global });
39308
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath));
39309
+ return new FirebenderIgnore({
39310
+ outputRoot,
39311
+ relativeDirPath: paths.relativeDirPath,
39312
+ relativeFilePath: paths.relativeFilePath,
39313
+ fileContent,
39314
+ validate,
39315
+ global
39316
+ });
39317
+ }
39318
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
39319
+ return new FirebenderIgnore({
39320
+ outputRoot,
39321
+ relativeDirPath,
39322
+ relativeFilePath,
39323
+ fileContent: "{}",
39324
+ validate: false,
39325
+ global
39326
+ });
39327
+ }
39328
+ };
39329
+ //#endregion
38494
39330
  //#region src/features/ignore/grokcli-ignore.ts
38495
39331
  /** The base built-in profile the rulesync profile extends (Grok's own default). */
38496
39332
  const GROKCLI_SANDBOX_BASE_PROFILE = "workspace";
@@ -39310,6 +40146,31 @@ const REASONIX_READ_TOOL_NAME = "read_file";
39310
40146
  * treated as rulesync-owned on generate so the dormant copy is replaced.
39311
40147
  */
39312
40148
  const REASONIX_LEGACY_READ_TOOL_NAME = "Read";
40149
+ async function isPathPresent(path) {
40150
+ return await fileExists(path) || await isPresentButUnresolvable(path);
40151
+ }
40152
+ /**
40153
+ * Warns when a project-scope generate is about to create `./reasonix.toml`
40154
+ * next to an existing `.reasonix/reasonix.toml`.
40155
+ *
40156
+ * Since v2.46.0 Reasonix reads `.reasonix/reasonix.toml` as the project config
40157
+ * when `./reasonix.toml` is absent, and never merges the two: once the root
40158
+ * file exists it wins, so every setting in the nested file (permissions,
40159
+ * sandbox, disabled tools, MCP servers, ...) silently stops applying. rulesync
40160
+ * writes only the root file, so it cannot keep the nested one in effect; this
40161
+ * at least names the problem, once per run however many of the features that
40162
+ * write the file (mcp, permissions, ignore) are generated. Like Reasonix, anything at the root path
40163
+ * (including a broken symlink) counts as the root file being there.
40164
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.47.1/internal/contract/config/paths.go
40165
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.47.1/docs/CONFIG_PATHS.md
40166
+ */
40167
+ async function warnIfReasonixNestedProjectConfigShadowed({ outputRoot, global, logger }) {
40168
+ if (global) return;
40169
+ if (await isPathPresent((0, node_path.join)(outputRoot, "reasonix.toml"))) return;
40170
+ if (!await isPathPresent((0, node_path.join)(outputRoot, REASONIX_NESTED_PROJECT_CONFIG_RELATIVE_FILE_PATH))) return;
40171
+ const nested = toPosixPath(REASONIX_NESTED_PROJECT_CONFIG_RELATIVE_FILE_PATH);
40172
+ warnOnceWithFallback(logger, `Reasonix: ${nested} exists but ${REASONIX_PROJECT_MCP_FILE_NAME} does not. Generating creates ${REASONIX_PROJECT_MCP_FILE_NAME}, and Reasonix then reads it instead of ${nested} (the two are never merged), so every setting in ${nested} stops applying. Move those settings into ${REASONIX_PROJECT_MCP_FILE_NAME} or the rulesync sources.`);
40173
+ }
39313
40174
  //#endregion
39314
40175
  //#region src/features/ignore/reasonix-ignore.ts
39315
40176
  const permissionsTableOf = (document) => toReasonixTable(document.permissions);
@@ -39393,7 +40254,12 @@ var ReasonixIgnore = class ReasonixIgnore extends ToolIgnore {
39393
40254
  fileContent: rulesyncPatterns.join("\n")
39394
40255
  });
39395
40256
  }
39396
- static async fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, global = false }) {
40257
+ static async fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, global = false, logger }) {
40258
+ await warnIfReasonixNestedProjectConfigShadowed({
40259
+ outputRoot,
40260
+ global,
40261
+ logger
40262
+ });
39397
40263
  const readDenies = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((pattern) => buildReadDenyEntry(pattern, REASONIX_READ_TOOL_NAME));
39398
40264
  const paths = this.getSettablePaths({ global });
39399
40265
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
@@ -39808,6 +40674,7 @@ var ZedIgnore = class ZedIgnore extends ToolIgnore {
39808
40674
  const IgnoreProcessorToolTargetSchema = zod_mini.z.enum(ignoreProcessorToolTargetTuple);
39809
40675
  const toolIgnoreFactories = /* @__PURE__ */ new Map([
39810
40676
  ["aiassistant", { class: AiassistantIgnore }],
40677
+ ["androidstudio", { class: AndroidstudioIgnore }],
39811
40678
  ["antigravity-cli", { class: AntigravityCliIgnore }],
39812
40679
  ["augmentcode", { class: AugmentcodeIgnore }],
39813
40680
  ["bob", { class: BobIgnore }],
@@ -39818,6 +40685,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
39818
40685
  ["continue", { class: ContinueIgnore }],
39819
40686
  ["crush", { class: CrushIgnore }],
39820
40687
  ["cursor", { class: CursorIgnore }],
40688
+ ["firebender", { class: FirebenderIgnore }],
39821
40689
  ["grokcli", { class: GrokcliIgnore }],
39822
40690
  ["hermesagent", { class: HermesagentIgnore }],
39823
40691
  ["junie", { class: JunieIgnore }],
@@ -39842,6 +40710,7 @@ const ignoreProcessorToolTargets = [...toolIgnoreFactories.keys()];
39842
40710
  const ignoreProcessorGlobalToolTargets = [
39843
40711
  "continue",
39844
40712
  "devin",
40713
+ "firebender",
39845
40714
  "grokcli",
39846
40715
  "kilo",
39847
40716
  "kiro",
@@ -39968,7 +40837,8 @@ var IgnoreProcessor = class extends FeatureProcessor {
39968
40837
  outputRoot: this.outputRoot,
39969
40838
  rulesyncIgnore,
39970
40839
  options: this.featureOptions,
39971
- global: this.global
40840
+ global: this.global,
40841
+ logger: this.logger
39972
40842
  });
39973
40843
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
39974
40844
  toolIgnore,
@@ -40318,13 +41188,31 @@ function renameServerField(servers, from, to) {
40318
41188
  return [name, Object.fromEntries(Object.entries(config).map(([key, value]) => key === from ? [to, value] : [key, value]))];
40319
41189
  }));
40320
41190
  }
41191
+ function isEmptyValue(value) {
41192
+ if (Array.isArray(value)) return value.length === 0;
41193
+ return isPlainObject$1(value) && Object.keys(value).length === 0;
41194
+ }
40321
41195
  /**
40322
41196
  * Antigravity uses `serverUrl` (not the canonical `url`) for HTTP/SSE MCP
40323
41197
  * servers. Convert the canonical shape into Antigravity's on the way out, and
40324
- * back to canonical on the way in, so round-tripping is lossless.
41198
+ * back to canonical on the way in.
41199
+ *
41200
+ * Antigravity documents no `type` field: the transport follows from `command`
41201
+ * (stdio) or `serverUrl` (Streamable HTTP / SSE), and `agy plugin validate`
41202
+ * reports the canonical `type` as an unknown field, so it is not written. A
41203
+ * remote server that carries `command`, `args` or `env` was also observed to be
41204
+ * refused at startup (runtime behavior reported by `agy plugin validate` 1.4.4,
41205
+ * not documented), so an empty `args` / `env` left on a `serverUrl` server is
41206
+ * dropped.
41207
+ * https://antigravity.google/docs/mcp
40325
41208
  */
40326
41209
  function toAntigravityMcpServers(servers) {
40327
- return renameServerField(servers, "url", "serverUrl");
41210
+ const renamed = renameServerField(servers, "url", "serverUrl");
41211
+ return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
41212
+ if (!isPlainObject$1(config)) return [name, config];
41213
+ const isRemote = typeof config.serverUrl === "string";
41214
+ return [name, Object.fromEntries(Object.entries(config).filter(([key, value]) => key !== "type" && !(isRemote && (key === "args" || key === "env") && isEmptyValue(value))))];
41215
+ }));
40328
41216
  }
40329
41217
  function toCanonicalMcpServers(servers) {
40330
41218
  return renameServerField(servers, "serverUrl", "url");
@@ -44514,6 +45402,60 @@ function applyDeepagentsCommand({ name, server, converted, logger }) {
44514
45402
  return true;
44515
45403
  }
44516
45404
  /**
45405
+ * Translate a canonical `oauth` block into dcode's custom Slack OAuth app
45406
+ * settings, or `undefined` (after warning) when it has to be left out.
45407
+ *
45408
+ * Since deepagents-code 0.1.87, `_validate_slack_oauth_config` accepts `oauth`
45409
+ * only as `{ client_id, client_secret? }` of non-empty strings, and only on a
45410
+ * `slack.com` / `*.slack.com` server with `auth: "oauth"`; anything else makes
45411
+ * dcode skip the whole server. The canonical block usually carries Claude
45412
+ * Code's `{ clientId, callbackPort, ... }` shape, so `clientId` becomes
45413
+ * `client_id` (a native `client_id` wins), `client_secret` passes through, and
45414
+ * every other key is dropped.
45415
+ *
45416
+ * @see https://github.com/langchain-ai/deepagents/blob/deepagents-code%3D%3D0.1.87/libs/code/deepagents_code/mcp_config.py
45417
+ */
45418
+ function toDeepagentsOauth({ name, oauth, converted, logger }) {
45419
+ if (!isSlackOauthServer(converted)) {
45420
+ logger?.warn(`${TOOL_NAME} MCP: dropping oauth on "${name}"; deepagents reads it only on a Slack (slack.com) server with auth: "oauth" and skips any other server that sets it.`);
45421
+ return;
45422
+ }
45423
+ const source = isRecord$1(oauth) ? oauth : {};
45424
+ const result = {};
45425
+ for (const [key, value] of Object.entries(source)) {
45426
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
45427
+ const nativeKey = key === "clientId" ? "client_id" : key;
45428
+ if (nativeKey !== "client_id" && nativeKey !== "client_secret") {
45429
+ logger?.warn(`${TOOL_NAME} MCP: dropping oauth.${key} on "${name}"; deepagents accepts only client_id and client_secret.`);
45430
+ continue;
45431
+ }
45432
+ if (nativeKey !== key && isDeepagentsOauthValue(source[nativeKey])) continue;
45433
+ if (!isDeepagentsOauthValue(value)) {
45434
+ logger?.warn(`${TOOL_NAME} MCP: dropping oauth.${key} on "${name}"; deepagents requires a non-empty string without control characters.`);
45435
+ continue;
45436
+ }
45437
+ result[nativeKey] = value;
45438
+ }
45439
+ if (result.client_id === void 0) {
45440
+ logger?.warn(`${TOOL_NAME} MCP: dropping oauth on "${name}"; deepagents requires oauth.client_id (canonical clientId) and skips a server without it.`);
45441
+ return;
45442
+ }
45443
+ return result;
45444
+ }
45445
+ function isDeepagentsOauthValue(value) {
45446
+ return typeof value === "string" && value.trim() !== "" && !/[\x00-\x1f\x7f]/.test(value);
45447
+ }
45448
+ function isSlackOauthServer(server) {
45449
+ if (server.auth !== "oauth" || typeof server.url !== "string") return false;
45450
+ let host;
45451
+ try {
45452
+ host = new URL(server.url).hostname.toLowerCase();
45453
+ } catch {
45454
+ return false;
45455
+ }
45456
+ return host === "slack.com" || host.endsWith(".slack.com");
45457
+ }
45458
+ /**
44517
45459
  * Translate one canonical server into dcode's `.mcp.json` shape, or `null` to
44518
45460
  * skip it.
44519
45461
  *
@@ -44536,7 +45478,7 @@ function toDeepagentsServer({ name, server, logger }) {
44536
45478
  reason: "the WebSocket transport, which deepagents does not support",
44537
45479
  logger
44538
45480
  });
44539
- const { enabledTools, disabledTools, type: _type, transport, disabled: _disabled, httpUrl, ...rest } = server;
45481
+ const { enabledTools, disabledTools, type: _type, transport, disabled: _disabled, httpUrl, oauth, ...rest } = server;
44540
45482
  const converted = { ...rest };
44541
45483
  if (!applyDeepagentsCommand({
44542
45484
  name,
@@ -44551,6 +45493,15 @@ function toDeepagentsServer({ name, server, logger }) {
44551
45493
  if (transport !== void 0) converted.transport = normalized;
44552
45494
  else converted.type = normalized;
44553
45495
  }
45496
+ if (oauth !== void 0) {
45497
+ const deepagentsOauth = toDeepagentsOauth({
45498
+ name,
45499
+ oauth,
45500
+ converted,
45501
+ logger
45502
+ });
45503
+ if (deepagentsOauth !== void 0) converted.oauth = deepagentsOauth;
45504
+ }
44554
45505
  if (enabledTools !== void 0 && disabledTools !== void 0) return warnAndSkipMcpServer({
44555
45506
  toolName: TOOL_NAME,
44556
45507
  serverName: name,
@@ -44577,6 +45528,13 @@ function toRulesyncServer(server) {
44577
45528
  const converted = { ...rest };
44578
45529
  for (const key of ["type", "transport"]) if (converted[key] === "streamable_http" || converted[key] === "streamable-http") converted[key] = "http";
44579
45530
  if (Array.isArray(allowedTools)) converted.enabledTools = allowedTools;
45531
+ if (isRecord$1(converted.oauth) && Object.hasOwn(converted.oauth, "client_id")) {
45532
+ const { client_id: clientId, ...oauthRest } = converted.oauth;
45533
+ converted.oauth = Object.hasOwn(oauthRest, "clientId") ? oauthRest : {
45534
+ clientId,
45535
+ ...oauthRest
45536
+ };
45537
+ }
44580
45538
  return converted;
44581
45539
  }
44582
45540
  var DeepagentsMcp = class DeepagentsMcp extends ToolMcp {
@@ -45339,6 +46297,171 @@ var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
45339
46297
  }
45340
46298
  };
45341
46299
  //#endregion
46300
+ //#region src/features/mcp/firebender-mcp.ts
46301
+ /**
46302
+ * The keys of the canonical server schema. Firebender reads only `command`,
46303
+ * `args`, `env`, `url` and `headers` of them, so the rest are dropped; a key
46304
+ * NOT on this list is unknown to rulesync and passed through untouched, so a
46305
+ * Firebender-native field such as `deferLoading` written in the tool-scoped
46306
+ * `firebender.mcpServers` block (or on a shared server) survives.
46307
+ */
46308
+ const CANONICAL_MCP_SERVER_KEYS$1 = new Set(Object.keys(McpServerSchema.def.shape));
46309
+ function passthroughNonCanonicalFields$1(serverConfig) {
46310
+ return Object.fromEntries(Object.entries(serverConfig).filter(([key]) => !CANONICAL_MCP_SERVER_KEYS$1.has(key)));
46311
+ }
46312
+ /**
46313
+ * Translate one canonical server into Firebender's `mcpServers` entry, or
46314
+ * return `null` (with a warning) for a server Firebender cannot express. A
46315
+ * stdio server is `{ command, args?, env? }`, a remote one `{ url, headers? }`;
46316
+ * Firebender has no `type` field and no per-server disable switch.
46317
+ *
46318
+ * @see https://docs.firebender.com/api-reference/syntax
46319
+ */
46320
+ function convertServerToFirebenderFormat({ serverName, serverConfig, logger }) {
46321
+ if (serverConfig.disabled === true) return warnAndSkipMcpServer({
46322
+ toolName: "Firebender",
46323
+ serverName,
46324
+ reason: "`disabled: true`, and Firebender has no per-server disable switch",
46325
+ logger
46326
+ });
46327
+ if (declaresNoTransport(serverConfig)) return warnAndSkipMcpServer({
46328
+ toolName: "Firebender",
46329
+ serverName,
46330
+ reason: "no command or url",
46331
+ logger
46332
+ });
46333
+ const passthrough = passthroughNonCanonicalFields$1(serverConfig);
46334
+ if (isRemoteMcpServer(serverConfig)) {
46335
+ const url = resolveRemoteMcpUrl(serverConfig);
46336
+ if (url === void 0) return warnAndSkipMcpServer({
46337
+ toolName: "Firebender",
46338
+ serverName,
46339
+ reason: "a remote transport but no url",
46340
+ logger
46341
+ });
46342
+ return {
46343
+ ...passthrough,
46344
+ url,
46345
+ ...serverConfig.headers !== void 0 && { headers: serverConfig.headers }
46346
+ };
46347
+ }
46348
+ const [command, ...args] = resolveLocalMcpCommand(serverConfig);
46349
+ if (command === void 0) return warnAndSkipMcpServer({
46350
+ toolName: "Firebender",
46351
+ serverName,
46352
+ reason: "a local transport but no command",
46353
+ logger
46354
+ });
46355
+ return {
46356
+ ...passthrough,
46357
+ command,
46358
+ ...args.length > 0 && { args },
46359
+ ...serverConfig.env !== void 0 && { env: serverConfig.env }
46360
+ };
46361
+ }
46362
+ /**
46363
+ * MCP generator for Firebender.
46364
+ *
46365
+ * Firebender reads MCP servers from the `mcpServers` key of `firebender.json`
46366
+ * (project root) and `~/.firebender/firebender.json` (personal; it wins on
46367
+ * name clashes). The file is shared with the ignore (`ignore`) and subagents
46368
+ * (`agents`) features and carries settings rulesync does not own, so only the
46369
+ * `mcpServers` key is written and the file is never deleted.
46370
+ *
46371
+ * @see https://docs.firebender.com/api-reference/syntax
46372
+ */
46373
+ var FirebenderMcp = class FirebenderMcp extends ToolMcp {
46374
+ json;
46375
+ constructor(params) {
46376
+ super(params);
46377
+ this.json = parseSharedConfig({
46378
+ format: "json",
46379
+ fileContent: this.fileContent || "{}"
46380
+ });
46381
+ }
46382
+ getJson() {
46383
+ return this.json;
46384
+ }
46385
+ static getSettablePaths({ global = false } = {}) {
46386
+ return {
46387
+ relativeDirPath: getFirebenderConfigDir({ global }),
46388
+ relativeFilePath: FIREBENDER_CONFIG_FILE_NAME
46389
+ };
46390
+ }
46391
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
46392
+ const paths = this.getSettablePaths({ global });
46393
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{}";
46394
+ return new FirebenderMcp({
46395
+ outputRoot,
46396
+ relativeDirPath: paths.relativeDirPath,
46397
+ relativeFilePath: paths.relativeFilePath,
46398
+ fileContent,
46399
+ validate,
46400
+ global
46401
+ });
46402
+ }
46403
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
46404
+ const paths = this.getSettablePaths({ global });
46405
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
46406
+ const existingContent = await readFileContentOrNull(filePath) ?? "{}";
46407
+ const mcpServers = {};
46408
+ for (const [serverName, serverConfig] of Object.entries(rulesyncMcp.getMcpServers())) {
46409
+ const converted = convertServerToFirebenderFormat({
46410
+ serverName,
46411
+ serverConfig,
46412
+ logger
46413
+ });
46414
+ if (converted !== null) mcpServers[serverName] = converted;
46415
+ }
46416
+ return new FirebenderMcp({
46417
+ outputRoot,
46418
+ relativeDirPath: paths.relativeDirPath,
46419
+ relativeFilePath: paths.relativeFilePath,
46420
+ fileContent: applySharedConfigPatch({
46421
+ fileKey: sharedConfigFileKey(paths),
46422
+ feature: "mcp",
46423
+ existingContent,
46424
+ patch: { mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : void 0 },
46425
+ filePath
46426
+ }),
46427
+ validate,
46428
+ global
46429
+ });
46430
+ }
46431
+ toRulesyncMcp() {
46432
+ const servers = this.json.mcpServers;
46433
+ const mcpServers = {};
46434
+ if (isMcpServers(servers)) {
46435
+ for (const [serverName, server] of Object.entries(servers)) if (server !== null && typeof server === "object" && !Array.isArray(server)) mcpServers[serverName] = server;
46436
+ }
46437
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
46438
+ }
46439
+ validate() {
46440
+ return {
46441
+ success: true,
46442
+ error: null
46443
+ };
46444
+ }
46445
+ /**
46446
+ * `firebender.json` is a user-managed file shared with other features (the
46447
+ * ignore feature's `ignore`, the subagents feature's `agents`), so it must
46448
+ * not be deleted.
46449
+ */
46450
+ isDeletable() {
46451
+ return false;
46452
+ }
46453
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
46454
+ return new FirebenderMcp({
46455
+ outputRoot,
46456
+ relativeDirPath,
46457
+ relativeFilePath,
46458
+ fileContent: "{}",
46459
+ validate: false,
46460
+ global
46461
+ });
46462
+ }
46463
+ };
46464
+ //#endregion
45342
46465
  //#region src/features/mcp/gitlabduo-mcp.ts
45343
46466
  const CANONICAL_TO_GITLABDUO_TRANSPORT = {
45344
46467
  local: "stdio",
@@ -46144,6 +47267,50 @@ function copyHermesOauth(source) {
46144
47267
  if (typeof source.cimd === "boolean") oauth.cimd = source.cimd;
46145
47268
  return Object.keys(oauth).length > 0 ? oauth : void 0;
46146
47269
  }
47270
+ const HERMES_COST_UNIT_MAX_LENGTH = 32;
47271
+ function describeHermesValue(value) {
47272
+ return typeof value === "number" ? String(value) : JSON.stringify(value) ?? String(value);
47273
+ }
47274
+ /**
47275
+ * Copies the per-server MCP cost keys Hermes v0.22.3 reads in
47276
+ * `tools/mcp_tool_costs.py`: `tool_costs` (declared per-tool costs keyed by
47277
+ * MCP tool name; each a number, a `[min, max]` range or `{credits, note}`),
47278
+ * `spend_limit` (per-chat budget for the server's costed tools; `0`/absent =
47279
+ * no limit) and `cost_unit` (label shown with costs; default `credits`).
47280
+ *
47281
+ * A costed call that would exceed `spend_limit` asks first even on a
47282
+ * `trust: full` server, so dropping the key on regenerate would silently
47283
+ * remove the guard. Values Hermes would reject when loading the server are
47284
+ * dropped with a warning instead of being written into a config Hermes then
47285
+ * refuses: `spend_limit` must be a finite non-negative number, `cost_unit` a
47286
+ * non-blank string of at most 32 characters, and `tool_costs` a mapping. The
47287
+ * mapping is rebuilt as an opaque object (so no reference is shared with the
47288
+ * source), with prototype-pollution keys removed at every depth since its
47289
+ * values may themselves be objects.
47290
+ */
47291
+ function copyHermesCostFields({ source, target, logger }) {
47292
+ let copied = false;
47293
+ const { spend_limit: spendLimit, cost_unit: costUnit, tool_costs: toolCosts } = source;
47294
+ if (spendLimit !== void 0) {
47295
+ if (typeof spendLimit === "number" && Number.isFinite(spendLimit) && spendLimit >= 0) {
47296
+ target.spend_limit = spendLimit;
47297
+ copied = true;
47298
+ } else logger?.warn(`Hermes MCP: dropping spend_limit ${describeHermesValue(spendLimit)}; Hermes requires a non-negative number.`);
47299
+ }
47300
+ if (costUnit !== void 0) {
47301
+ if (typeof costUnit === "string" && costUnit.trim() !== "" && costUnit.length <= HERMES_COST_UNIT_MAX_LENGTH) {
47302
+ target.cost_unit = costUnit;
47303
+ copied = true;
47304
+ } else logger?.warn(`Hermes MCP: dropping cost_unit ${describeHermesValue(costUnit)}; Hermes requires a non-empty string of at most ${HERMES_COST_UNIT_MAX_LENGTH} characters.`);
47305
+ }
47306
+ if (toolCosts !== void 0) {
47307
+ if (isPlainObject$1(toolCosts)) {
47308
+ target.tool_costs = omitPrototypePollutionKeysDeep(toolCosts);
47309
+ copied = true;
47310
+ } else logger?.warn("Hermes MCP: dropping tool_costs; Hermes requires a mapping of MCP tool names to costs.");
47311
+ }
47312
+ return copied;
47313
+ }
46147
47314
  const HERMES_OPAQUE_MAPPING_KEYS = [
46148
47315
  "sampling",
46149
47316
  "elicitation",
@@ -46155,13 +47322,18 @@ const HERMES_OPAQUE_MAPPING_KEYS = [
46155
47322
  * alias — `auth` (`oauth` for OAuth 2.1/PKCE), mTLS `client_cert` (string PEM
46156
47323
  * path, or `[cert, key]`/`[cert, key, password]` list) and `client_key`,
46157
47324
  * `connect_timeout` (seconds), `supports_parallel_tool_calls`, `protocol`,
46158
- * `lazy`, `keepalive_interval`, `elicitation`, `trust`, and `identity_header` —
47325
+ * `lazy`, `keepalive_interval`, `elicitation`, `trust`, `identity_header`, and
47326
+ * the cost keys (see {@link copyHermesCostFields}) —
46159
47327
  * verbatim from `source` to `target`. Field names are identical on both sides
46160
47328
  * (the canonical `McpServerSchema` is a `looseObject`), so this serves export
46161
47329
  * and import alike. See the Hermes mcp-config-reference.
46162
47330
  */
46163
- function copyHermesAdvancedFields(source, target) {
46164
- let copied = false;
47331
+ function copyHermesAdvancedFields(source, target, logger) {
47332
+ let copied = copyHermesCostFields({
47333
+ source,
47334
+ target,
47335
+ logger
47336
+ });
46165
47337
  if (typeof source.auth === "string") {
46166
47338
  target.auth = source.auth;
46167
47339
  copied = true;
@@ -46270,7 +47442,7 @@ function applyHermesToolsBlock(hermesTools, server) {
46270
47442
  * as Hermes's own `transport: sse`, without which Hermes would connect to it
46271
47443
  * over Streamable HTTP.
46272
47444
  */
46273
- function convertServerToHermes(config) {
47445
+ function convertServerToHermes(config, logger) {
46274
47446
  const out = {};
46275
47447
  const command = config.command;
46276
47448
  const url = resolveHermesUrl(config);
@@ -46293,7 +47465,7 @@ function convertServerToHermes(config) {
46293
47465
  if (config.disabled === true) out.enabled = false;
46294
47466
  const timeout = resolveHermesTimeout(config);
46295
47467
  if (timeout !== void 0) out.timeout = timeout;
46296
- copyHermesAdvancedFields(config, out);
47468
+ copyHermesAdvancedFields(config, out, logger);
46297
47469
  const tools = buildHermesToolsBlock(config);
46298
47470
  if (Object.keys(tools).length > 0) out.tools = tools;
46299
47471
  return out;
@@ -46301,11 +47473,11 @@ function convertServerToHermes(config) {
46301
47473
  /**
46302
47474
  * Converts rulesync canonical MCP servers into Hermes `mcp_servers:` entries.
46303
47475
  */
46304
- function convertToHermesFormat(mcpServers) {
47476
+ function convertToHermesFormat(mcpServers, logger) {
46305
47477
  const result = {};
46306
47478
  for (const [name, config] of Object.entries(mcpServers)) {
46307
47479
  if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
46308
- result[name] = convertServerToHermes(config);
47480
+ result[name] = convertServerToHermes(config, logger);
46309
47481
  }
46310
47482
  return result;
46311
47483
  }
@@ -46325,7 +47497,7 @@ function mergeHermesMcpServers(config, mcpServers) {
46325
47497
  * Mirrors {@link convertToHermesFormat}: `enabled: false` maps back to the
46326
47498
  * canonical `disabled: true`, and only recognized fields are carried over.
46327
47499
  */
46328
- function convertFromHermesFormat(mcpServers) {
47500
+ function convertFromHermesFormat(mcpServers, logger) {
46329
47501
  const result = {};
46330
47502
  const hermesOverrides = {};
46331
47503
  for (const [name, config] of Object.entries(mcpServers)) {
@@ -46342,7 +47514,7 @@ function convertFromHermesFormat(mcpServers) {
46342
47514
  if (isRecord$1(config.tools)) applyHermesToolsBlock(config.tools, server);
46343
47515
  result[name] = server;
46344
47516
  const hermesServer = { ...server };
46345
- if (copyHermesAdvancedFields(config, hermesServer)) hermesOverrides[name] = hermesServer;
47517
+ if (copyHermesAdvancedFields(config, hermesServer, logger)) hermesOverrides[name] = hermesServer;
46346
47518
  }
46347
47519
  return {
46348
47520
  mcpServers: result,
@@ -46361,8 +47533,10 @@ function convertFromHermesFormat(mcpServers) {
46361
47533
  */
46362
47534
  var HermesagentMcp = class HermesagentMcp extends ToolMcp {
46363
47535
  config;
46364
- constructor(params) {
47536
+ logger;
47537
+ constructor({ logger, ...params }) {
46365
47538
  super(params);
47539
+ this.logger = logger;
46366
47540
  this.config = this.fileContent !== void 0 ? parseSharedConfig({
46367
47541
  format: "yaml",
46368
47542
  fileContent: this.fileContent
@@ -46406,7 +47580,7 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
46406
47580
  static getExtraSharedWritePaths() {
46407
47581
  return getHermesagentSharedConfigWritePaths();
46408
47582
  }
46409
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
47583
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false, logger }) {
46410
47584
  if (!global) throw new Error(HERMESAGENT_GLOBAL_ONLY_MESSAGE);
46411
47585
  const paths = this.getSettablePaths({ global });
46412
47586
  const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "";
@@ -46416,17 +47590,18 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
46416
47590
  relativeFilePath: paths.relativeFilePath,
46417
47591
  fileContent,
46418
47592
  validate,
46419
- global
47593
+ global,
47594
+ logger
46420
47595
  });
46421
47596
  }
46422
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
47597
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
46423
47598
  if (!global) throw new Error(HERMESAGENT_GLOBAL_ONLY_MESSAGE);
46424
47599
  const paths = this.getSettablePaths({ global });
46425
47600
  const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "";
46426
47601
  const merged = mergeHermesMcpServers(parseSharedConfig({
46427
47602
  format: "yaml",
46428
47603
  fileContent
46429
- }), convertToHermesFormat(rulesyncMcp.getMcpServers()));
47604
+ }), convertToHermesFormat(rulesyncMcp.getMcpServers(), logger));
46430
47605
  return new HermesagentMcp({
46431
47606
  outputRoot,
46432
47607
  relativeDirPath: paths.relativeDirPath,
@@ -46438,11 +47613,12 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
46438
47613
  patch: { mcp_servers: merged.mcp_servers }
46439
47614
  }),
46440
47615
  validate,
46441
- global
47616
+ global,
47617
+ logger
46442
47618
  });
46443
47619
  }
46444
47620
  toRulesyncMcp() {
46445
- const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {});
47621
+ const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {}, this.logger);
46446
47622
  return this.toRulesyncMcpDefault({
46447
47623
  outputRoot: getHermesagentRulesyncOutputRoot({
46448
47624
  nativeOutputRoot: this.outputRoot,
@@ -47326,14 +48502,15 @@ async function readKimiCodeMcpDefaults({ outputRoot }) {
47326
48502
  if (!config.parsed) return {};
47327
48503
  return {
47328
48504
  ...typeof config.mcp.startup_timeout_ms === "number" && { startupTimeoutMs: config.mcp.startup_timeout_ms },
47329
- ...typeof config.mcp.tool_timeout_ms === "number" && { toolTimeoutMs: config.mcp.tool_timeout_ms }
48505
+ ...typeof config.mcp.tool_timeout_ms === "number" && { toolTimeoutMs: config.mcp.tool_timeout_ms },
48506
+ ...typeof config.mcp.default_deferred === "boolean" && { defaultDeferred: config.mcp.default_deferred }
47330
48507
  };
47331
48508
  }
47332
48509
  /**
47333
48510
  * Kimi's `[mcp]` section in the shared user config sets the default connect and
47334
- * per-tool-call timeouts for *every* MCP server, including ones rulesync did not
47335
- * write. Per-server values in `mcp.json` still take precedence; this only moves
47336
- * the floor.
48511
+ * per-tool-call timeouts, and whether tools load on demand, for *every* MCP
48512
+ * server, including ones rulesync did not write. Per-server values in
48513
+ * `mcp.json` still take precedence; this only moves the default.
47337
48514
  *
47338
48515
  * Written as an auxiliary file so it goes through the normal write phase and
47339
48516
  * respects `--dry-run`, and merged through the shared-config gateway so the
@@ -47341,7 +48518,8 @@ async function readKimiCodeMcpDefaults({ outputRoot }) {
47341
48518
  *
47342
48519
  * The gateway replaces an owned key wholesale, and `mcp` is a table, so the
47343
48520
  * section is recomputed from the existing file: authoring only
47344
- * `startupTimeoutMs` must not delete a hand-written `tool_timeout_ms`. Same
48521
+ * `startupTimeoutMs` must not delete a hand-written `tool_timeout_ms` or
48522
+ * `default_deferred`. Same
47345
48523
  * approach the `.vibe/config.toml` `tools` writer takes.
47346
48524
  *
47347
48525
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#mcp
@@ -47368,7 +48546,8 @@ var KimiCodeMcpConfigToml = class KimiCodeMcpConfigToml extends ToolFile {
47368
48546
  patch: { mcp: {
47369
48547
  ...existingSection,
47370
48548
  ...defaults.startupTimeoutMs !== void 0 && { startup_timeout_ms: defaults.startupTimeoutMs },
47371
- ...defaults.toolTimeoutMs !== void 0 && { tool_timeout_ms: defaults.toolTimeoutMs }
48549
+ ...defaults.toolTimeoutMs !== void 0 && { tool_timeout_ms: defaults.toolTimeoutMs },
48550
+ ...defaults.defaultDeferred !== void 0 && { default_deferred: defaults.defaultDeferred }
47372
48551
  } },
47373
48552
  filePath: relativeConfigPath
47374
48553
  });
@@ -47466,18 +48645,20 @@ var KimiCodeMcp = class KimiCodeMcp extends ToolMcp {
47466
48645
  if (!isRecord$1(block)) return [];
47467
48646
  const startupTimeoutMs = typeof block.startupTimeoutMs === "number" ? block.startupTimeoutMs : void 0;
47468
48647
  const toolTimeoutMs = typeof block.toolTimeoutMs === "number" ? block.toolTimeoutMs : void 0;
47469
- if (startupTimeoutMs === void 0 && toolTimeoutMs === void 0) return [];
48648
+ const defaultDeferred = typeof block.defaultDeferred === "boolean" ? block.defaultDeferred : void 0;
48649
+ if (startupTimeoutMs === void 0 && toolTimeoutMs === void 0 && defaultDeferred === void 0) return [];
47470
48650
  const existing = await readKimiCodeConfig({ outputRoot });
47471
48651
  if (!existing.parsed) {
47472
48652
  const paths = kimiCodeConfigRelativePath();
47473
- warnWithFallback(logger, `Skipping the Kimi Code MCP timeout defaults: ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} is not valid TOML.`);
48653
+ warnWithFallback(logger, `Skipping the Kimi Code MCP [mcp] defaults: ${(0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)} is not valid TOML.`);
47474
48654
  return [];
47475
48655
  }
47476
48656
  return [KimiCodeMcpConfigToml.fromDefaults({
47477
48657
  outputRoot,
47478
48658
  defaults: {
47479
48659
  startupTimeoutMs,
47480
- toolTimeoutMs
48660
+ toolTimeoutMs,
48661
+ defaultDeferred
47481
48662
  },
47482
48663
  existing
47483
48664
  })];
@@ -50053,6 +51234,11 @@ var ReasonixMcp = class ReasonixMcp extends ToolMcp {
50053
51234
  });
50054
51235
  }
50055
51236
  static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
51237
+ await warnIfReasonixNestedProjectConfigShadowed({
51238
+ outputRoot,
51239
+ global,
51240
+ logger
51241
+ });
50056
51242
  const paths = this.getSettablePaths({ global });
50057
51243
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
50058
51244
  const existingContent = await readFileContentOrNull(filePath) ?? "";
@@ -52030,6 +53216,397 @@ var WarpcliMcp = class extends WarpMcp {
52030
53216
  }
52031
53217
  };
52032
53218
  //#endregion
53219
+ //#region src/constants/xum-paths.ts
53220
+ /**
53221
+ * Xum configuration-layout conventions.
53222
+ *
53223
+ * Xum (Coder's desktop app and `xum run` CLI for running coding agents in
53224
+ * parallel workspaces, formerly Mux) keeps its project assets under a `.xum/`
53225
+ * directory at the project root and the matching user assets under `~/.xum/`.
53226
+ * Legacy `.mux/` files are still read when the matching `.xum/` file is
53227
+ * missing; rulesync writes only the `.xum/` paths.
53228
+ *
53229
+ * @see https://xum.cdr.dev/reference/mux-compatibility
53230
+ */
53231
+ const XUM_DIR = ".xum";
53232
+ const XUM_RULE_FILE_NAME = "AGENTS.md";
53233
+ const XUM_LOCAL_RULE_FILE_NAME = "AGENTS.local.md";
53234
+ const XUM_MCP_FILE_NAME = "mcp.jsonc";
53235
+ const XUM_AGENTS_DIR_PATH = (0, node_path.join)(XUM_DIR, "agents");
53236
+ const XUM_SKILLS_DIR_PATH = (0, node_path.join)(XUM_DIR, "skills");
53237
+ //#endregion
53238
+ //#region src/features/mcp/xum-mcp.ts
53239
+ const EMPTY_XUM_MCP_CONFIG = JSON.stringify({ servers: {} }, null, 2);
53240
+ /** Characters that never need quoting in a POSIX shell word. */
53241
+ const SHELL_SAFE_WORD = /^[A-Za-z0-9_\-.,:/@%+=]+$/;
53242
+ /**
53243
+ * A command string free of quotes, escapes, expansions and operators, which
53244
+ * can be split on whitespace back into argv without changing its meaning.
53245
+ */
53246
+ const SPLIT_SAFE_COMMAND = /^[^'"\\$`|&;<>(){}*?[\]~#!\n]+$/;
53247
+ /** Single-quote one argv element for the POSIX shell Xum runs commands in. */
53248
+ function quoteShellWord(value) {
53249
+ if (SHELL_SAFE_WORD.test(value)) return value;
53250
+ return `'${value.replaceAll("'", `'\\''`)}'`;
53251
+ }
53252
+ /**
53253
+ * Build the shell command string a Xum stdio server is launched with. A
53254
+ * `command` string is kept verbatim (Xum itself treats it as a raw shell
53255
+ * string, so `npx -y pkg` authored as one string keeps working); an explicit
53256
+ * argv (`command` as an array) and every `args` element are quoted where
53257
+ * needed, so `/opt/my tools/srv` stays one word.
53258
+ */
53259
+ function buildCommandString(server) {
53260
+ const args = server.args ?? [];
53261
+ if (Array.isArray(server.command)) {
53262
+ const [executable] = server.command;
53263
+ if (executable === void 0 || executable.trim() === "") return void 0;
53264
+ return [...server.command, ...args].map(quoteShellWord).join(" ");
53265
+ }
53266
+ if (typeof server.command !== "string" || server.command.trim() === "") return void 0;
53267
+ return [server.command, ...args.map(quoteShellWord)].join(" ");
53268
+ }
53269
+ /** A name bash accepts on the left of a `NAME=value` assignment. */
53270
+ const SHELL_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
53271
+ /** A canonical `${VAR}` reference whose name bash can expand. */
53272
+ const ENV_VAR_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
53273
+ /**
53274
+ * Quote an env value for a `NAME=value` assignment. Literal text is
53275
+ * single-quoted, so nothing in it is expanded or run, while every canonical
53276
+ * `${VAR}` reference is emitted as `"${VAR}"` so bash still expands it from
53277
+ * the environment Xum starts the server with. The pieces are concatenated into
53278
+ * one shell word: `Bearer ${TOKEN}` becomes `'Bearer '"${TOKEN}"`.
53279
+ */
53280
+ function quoteEnvValue(value) {
53281
+ if (value === "") return "''";
53282
+ const parts = [];
53283
+ let last = 0;
53284
+ for (const match of value.matchAll(ENV_VAR_REFERENCE)) {
53285
+ if (match.index > last) parts.push(quoteShellWord(value.slice(last, match.index)));
53286
+ parts.push(`"\${${match[1]}}"`);
53287
+ last = match.index + match[0].length;
53288
+ }
53289
+ if (last < value.length) parts.push(quoteShellWord(value.slice(last)));
53290
+ return parts.join("");
53291
+ }
53292
+ /**
53293
+ * Build the `NAME=value ` prefix that sets a stdio server's `env` for its
53294
+ * command only (Xum runs the command string with bash). Returns the prefix
53295
+ * plus the entries left out: names that cannot be bash assignment targets and
53296
+ * entries whose value is not a string.
53297
+ */
53298
+ function buildEnvPrefix(env) {
53299
+ const assignments = [];
53300
+ const invalidNames = [];
53301
+ const nonStringNames = [];
53302
+ for (const [name, value] of Object.entries(env)) {
53303
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
53304
+ if (!SHELL_ENV_NAME.test(name)) {
53305
+ invalidNames.push(name);
53306
+ continue;
53307
+ }
53308
+ if (typeof value !== "string") {
53309
+ nonStringNames.push(name);
53310
+ continue;
53311
+ }
53312
+ assignments.push(`${name}=${quoteEnvValue(value)}`);
53313
+ }
53314
+ return {
53315
+ prefix: assignments.map((assignment) => `${assignment} `).join(""),
53316
+ invalidNames,
53317
+ nonStringNames
53318
+ };
53319
+ }
53320
+ /** Bash reserved words; a command starting with one cannot take an assignment prefix. */
53321
+ const BASH_RESERVED_WORDS = /* @__PURE__ */ new Set([
53322
+ "!",
53323
+ "[[",
53324
+ "]]",
53325
+ "{",
53326
+ "}",
53327
+ "case",
53328
+ "coproc",
53329
+ "do",
53330
+ "done",
53331
+ "elif",
53332
+ "else",
53333
+ "esac",
53334
+ "fi",
53335
+ "for",
53336
+ "function",
53337
+ "if",
53338
+ "in",
53339
+ "select",
53340
+ "then",
53341
+ "time",
53342
+ "until",
53343
+ "while"
53344
+ ]);
53345
+ /**
53346
+ * Explain why a `NAME=value` prefix would not reach the server, or return
53347
+ * `undefined` when it would. Bash applies such assignments to the first simple
53348
+ * command only: with a list or pipeline (`cd x && srv`) the server would not
53349
+ * see them, and in front of a group, parenthesized list or reserved word (`( srv )`,
53350
+ * `{ srv; }`, `if ...`) they are a syntax error that keeps the server from
53351
+ * starting. Only the verbatim `command` string is checked for operators,
53352
+ * outside complete single-quoted words; argv elements are quoted by
53353
+ * {@link quoteShellWord}.
53354
+ */
53355
+ function envPrefixBlocker(server, baseCommand) {
53356
+ if (typeof server.command === "string") {
53357
+ const unquoted = server.command.replace(/'[^']*'/g, "");
53358
+ if (unquoted.includes("'") || /[;&|(){}\n]/.test(unquoted)) return "its command string contains shell operators, grouping or a newline, so bash would not pass NAME=value assignments in front of it to the server";
53359
+ }
53360
+ const [firstWord] = baseCommand.trimStart().split(/\s+/);
53361
+ if (firstWord !== void 0 && BASH_RESERVED_WORDS.has(firstWord)) return `its command starts with the bash reserved word ${quoteValueForWarning(firstWord)}, which cannot follow NAME=value assignments`;
53362
+ }
53363
+ /**
53364
+ * One leading `NAME=value` assignment in exactly the shape {@link quoteEnvValue}
53365
+ * writes: bare shell-safe characters, single-quoted text (with the `'\''`
53366
+ * escape) and `"${VAR}"` references, followed by spaces or tabs (a newline
53367
+ * would end the command, so the assignment would not apply to what follows). Bare characters
53368
+ * are matched one at a time, so every alternative starts with a distinct
53369
+ * character and the pattern cannot backtrack exponentially.
53370
+ */
53371
+ const LEADING_ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=((?:[A-Za-z0-9_\-.,:/@%+=]|'[^']*'|\\'|"\$\{[A-Za-z_][A-Za-z0-9_]*\}")*)[ \t]+/;
53372
+ /** Decode a value written by {@link quoteEnvValue} back into its canonical form. */
53373
+ function unquoteEnvValue(quoted) {
53374
+ return quoted.replace(/'([^']*)'|\\'|"(\$\{[A-Za-z_][A-Za-z0-9_]*\})"/g, (_m, single, ref) => typeof single === "string" ? single : typeof ref === "string" ? ref : "'");
53375
+ }
53376
+ /**
53377
+ * Split leading `NAME=value` assignments off a command string, but only when
53378
+ * every one of them is in the shape rulesync writes and a plain command
53379
+ * follows (see {@link envPrefixBlocker}); anything else (for example a `$(...)` substitution) keeps the whole string as
53380
+ * the command.
53381
+ */
53382
+ function splitEnvPrefix(command) {
53383
+ const env = {};
53384
+ let rest = command.trimStart();
53385
+ for (;;) {
53386
+ const match = LEADING_ENV_ASSIGNMENT.exec(rest);
53387
+ if (match === null) break;
53388
+ const [whole, name, quoted] = match;
53389
+ if (name === void 0 || quoted === void 0 || PROTOTYPE_POLLUTION_KEYS.has(name)) break;
53390
+ env[name] = unquoteEnvValue(quoted);
53391
+ rest = rest.slice(whole.length);
53392
+ }
53393
+ if (Object.keys(env).length === 0 || rest.trim() === "" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest) || envPrefixBlocker({ command: rest }, rest) !== void 0) return { rest: command };
53394
+ return {
53395
+ env,
53396
+ rest
53397
+ };
53398
+ }
53399
+ /** Read a command string back into `env` + `command` + `args`. */
53400
+ function parseCommandString(command) {
53401
+ const { env, rest } = splitEnvPrefix(command);
53402
+ return {
53403
+ ...env !== void 0 && { env },
53404
+ ...splitCommandString(rest)
53405
+ };
53406
+ }
53407
+ /** Split a plain command string back into `command` + `args` when it is safe to. */
53408
+ function splitCommandString(command) {
53409
+ const trimmed = command.trim();
53410
+ if (!SPLIT_SAFE_COMMAND.test(trimmed)) return { command };
53411
+ const [head, ...rest] = trimmed.split(/\s+/);
53412
+ if (head === void 0) return { command };
53413
+ return rest.length > 0 ? {
53414
+ command: head,
53415
+ args: rest
53416
+ } : { command: head };
53417
+ }
53418
+ function resolveXumServerKind(server) {
53419
+ const candidate = server.type ?? server.transport;
53420
+ if (candidate === "stdio" || candidate === "local") return "stdio";
53421
+ if (candidate === "http" || candidate === "streamable-http") return "http";
53422
+ if (candidate === "sse") return "sse";
53423
+ if (candidate !== void 0) return { unsupported: candidate };
53424
+ if (server.command !== void 0) return "stdio";
53425
+ const url = server.url ?? server.httpUrl;
53426
+ if (typeof url === "string") return /^wss?:\/\//i.test(url) ? { unsupported: "ws" } : "auto";
53427
+ }
53428
+ /** Convert one canonical server into a Xum `servers` entry, or `null` to skip it. */
53429
+ function convertServerToXum({ name, server, logger }) {
53430
+ const skip = (reason) => {
53431
+ warnWithFallback(logger, `Xum MCP: skipping ${quoteValueForWarning(name)} because ${reason}.`);
53432
+ return null;
53433
+ };
53434
+ const kind = resolveXumServerKind(server);
53435
+ if (kind === void 0) return skip("it has neither a command nor a URL");
53436
+ if (typeof kind === "object") return skip(`it uses the "${kind.unsupported}" transport, which Xum does not support (only stdio, http and sse)`);
53437
+ const settings = {};
53438
+ if (server.disabled === true) settings.disabled = true;
53439
+ if (isStringArray$1(server.enabledTools)) settings.toolAllowlist = server.enabledTools;
53440
+ if (kind !== "stdio") {
53441
+ const url = server.url ?? server.httpUrl;
53442
+ if (typeof url !== "string" || url === "") return skip("it has no URL");
53443
+ return {
53444
+ ...kind !== "auto" && { transport: kind },
53445
+ url,
53446
+ ...isPlainObject$1(server.headers) && Object.keys(server.headers).length > 0 && { headers: server.headers },
53447
+ ...settings
53448
+ };
53449
+ }
53450
+ const baseCommand = buildCommandString(server);
53451
+ if (baseCommand === void 0) return skip("it has no command");
53452
+ let command = baseCommand;
53453
+ if (isPlainObject$1(server.env) && Object.keys(server.env).length > 0) {
53454
+ const blocker = envPrefixBlocker(server, baseCommand);
53455
+ if (blocker !== void 0) warnWithFallback(logger, `Xum MCP: ${quoteValueForWarning(name)} sets env, but ${blocker}; the server is written without env.`);
53456
+ else {
53457
+ const { prefix, invalidNames, nonStringNames } = buildEnvPrefix(server.env);
53458
+ command = `${prefix}${baseCommand}`;
53459
+ if (invalidNames.length > 0) warnWithFallback(logger, `Xum MCP: ${quoteValueForWarning(name)} sets env ${invalidNames.map(quoteValueForWarning).join(", ")}, which cannot be a bash variable name; the server is written without it.`);
53460
+ if (nonStringNames.length > 0) warnWithFallback(logger, `Xum MCP: ${quoteValueForWarning(name)} sets env ${nonStringNames.map(quoteValueForWarning).join(", ")} to a value that is not a string; the server is written without it.`);
53461
+ }
53462
+ }
53463
+ if (typeof server.cwd === "string") warnWithFallback(logger, `Xum MCP: ${quoteValueForWarning(name)} sets cwd, which Xum's mcp.jsonc cannot express; the server is written without it (Xum starts stdio servers in the workspace directory).`);
53464
+ return Object.keys(settings).length > 0 ? {
53465
+ command,
53466
+ ...settings
53467
+ } : command;
53468
+ }
53469
+ /** Copy only the string values of a header map (secret references have no canonical form). */
53470
+ function copyStringHeaders(value) {
53471
+ if (!isPlainObject$1(value)) return void 0;
53472
+ const result = {};
53473
+ for (const [key, entry] of Object.entries(value)) if (!PROTOTYPE_POLLUTION_KEYS.has(key) && typeof entry === "string") result[key] = entry;
53474
+ return Object.keys(result).length > 0 ? result : void 0;
53475
+ }
53476
+ /** Read a Xum `servers` map back into the canonical shape. */
53477
+ function convertFromXumFormat(servers) {
53478
+ if (!isPlainObject$1(servers)) return {};
53479
+ const result = {};
53480
+ for (const [name, entry] of Object.entries(servers)) {
53481
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
53482
+ if (typeof entry === "string") {
53483
+ result[name] = parseCommandString(entry);
53484
+ continue;
53485
+ }
53486
+ if (!isPlainObject$1(entry)) continue;
53487
+ const server = {};
53488
+ if (typeof entry.url === "string") {
53489
+ if (entry.transport === "http" || entry.transport === "sse") server.type = entry.transport;
53490
+ server.url = entry.url;
53491
+ const headers = copyStringHeaders(entry.headers);
53492
+ if (headers !== void 0) server.headers = headers;
53493
+ } else if (typeof entry.command === "string") Object.assign(server, parseCommandString(entry.command));
53494
+ else continue;
53495
+ if (entry.disabled === true) server.disabled = true;
53496
+ if (isStringArray$1(entry.toolAllowlist)) server.enabledTools = entry.toolAllowlist;
53497
+ result[name] = server;
53498
+ }
53499
+ return result;
53500
+ }
53501
+ /**
53502
+ * Xum MCP configuration.
53503
+ *
53504
+ * Xum reads `~/.xum/mcp.jsonc` (user) and the repo override `.xum/mcp.jsonc`
53505
+ * (project; a server there wins over the user one of the same name). Both are
53506
+ * a JSONC document with a `servers` map:
53507
+ *
53508
+ * - a stdio server is a shell command string run with bash (the object form
53509
+ * `{ command }` carries `disabled` / `toolAllowlist`); `env` is written as
53510
+ * `NAME=value` assignments in front of the command, and `cwd`, which has no
53511
+ * equivalent, is dropped with a warning;
53512
+ * - a remote server is `{ transport: "http" | "sse" | "auto", url, headers }`
53513
+ * (the transport defaults to auto-detection).
53514
+ *
53515
+ * rulesync owns only `servers`; other top-level keys (Xum writes
53516
+ * `enabledPluginServers` into the user file) are preserved. The project file
53517
+ * may be removed by `--delete`; the user file never is.
53518
+ *
53519
+ * @see https://xum.cdr.dev/config/mcp-servers
53520
+ * @see https://github.com/coder/xum/blob/main/src/node/services/mcpConfigService.ts
53521
+ */
53522
+ var XumMcp = class XumMcp extends ToolMcp {
53523
+ json;
53524
+ constructor(params) {
53525
+ super(params);
53526
+ this.json = parseSharedConfig({
53527
+ format: "jsonc",
53528
+ fileContent: this.fileContent,
53529
+ filePath: (0, node_path.join)(this.relativeDirPath, this.relativeFilePath),
53530
+ invalidRootPolicy: "error",
53531
+ jsoncParseErrors: "error"
53532
+ });
53533
+ }
53534
+ getJson() {
53535
+ return this.json;
53536
+ }
53537
+ isDeletable() {
53538
+ return !this.global;
53539
+ }
53540
+ static getSettablePaths(_options = {}) {
53541
+ return {
53542
+ relativeDirPath: XUM_DIR,
53543
+ relativeFilePath: XUM_MCP_FILE_NAME
53544
+ };
53545
+ }
53546
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
53547
+ const paths = this.getSettablePaths({ global });
53548
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? EMPTY_XUM_MCP_CONFIG;
53549
+ return new XumMcp({
53550
+ outputRoot,
53551
+ relativeDirPath: paths.relativeDirPath,
53552
+ relativeFilePath: paths.relativeFilePath,
53553
+ fileContent,
53554
+ validate,
53555
+ global
53556
+ });
53557
+ }
53558
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
53559
+ const paths = this.getSettablePaths({ global });
53560
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
53561
+ const existingContent = await readFileContentOrNull(filePath) ?? EMPTY_XUM_MCP_CONFIG;
53562
+ const servers = {};
53563
+ for (const [name, server] of Object.entries(rulesyncMcp.getMcpServers())) {
53564
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
53565
+ const entry = convertServerToXum({
53566
+ name,
53567
+ server,
53568
+ logger
53569
+ });
53570
+ if (entry !== null) servers[name] = entry;
53571
+ }
53572
+ return new XumMcp({
53573
+ outputRoot,
53574
+ relativeDirPath: paths.relativeDirPath,
53575
+ relativeFilePath: paths.relativeFilePath,
53576
+ fileContent: applySharedConfigPatch({
53577
+ fileKey: sharedConfigFileKey(paths),
53578
+ feature: "mcp",
53579
+ existingContent,
53580
+ patch: { servers },
53581
+ filePath,
53582
+ logger
53583
+ }),
53584
+ validate,
53585
+ global
53586
+ });
53587
+ }
53588
+ toRulesyncMcp() {
53589
+ const mcpServers = convertFromXumFormat(this.json.servers);
53590
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
53591
+ }
53592
+ validate() {
53593
+ return {
53594
+ success: true,
53595
+ error: null
53596
+ };
53597
+ }
53598
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
53599
+ return new XumMcp({
53600
+ outputRoot,
53601
+ relativeDirPath,
53602
+ relativeFilePath,
53603
+ fileContent: "{}",
53604
+ validate: false,
53605
+ global
53606
+ });
53607
+ }
53608
+ };
53609
+ //#endregion
52033
53610
  //#region src/features/mcp/zcode-mcp.ts
52034
53611
  /**
52035
53612
  * Single spelling of the config.json codec/policy, matching the
@@ -52063,7 +53640,7 @@ function asZcodeRemoteType(stated, url) {
52063
53640
  }
52064
53641
  /**
52065
53642
  * The keys each OAuth flow of ZCode's per-server `oauth` union accepts on a
52066
- * remote server, and which of them are required. Read off the 3.14.3/3.15.3
53643
+ * remote server, and which of them are required. Read off the 3.14.3–3.16.2
52067
53644
  * runtime config schema, since ZCode's MCP page only says to "enable OAuth for
52068
53645
  * the service in its configuration". Both variants are strict, so any other
52069
53646
  * key would make ZCode skip the server.
@@ -52167,7 +53744,11 @@ function resolveZcodeTimeoutMs({ serverName, config, logger }) {
52167
53744
  }
52168
53745
  /**
52169
53746
  * The per-server keys ZCode reads on every transport: `timeoutMs`,
52170
- * `protocolVersion` and the enable toggle (`enableKey`).
53747
+ * `protocolVersion`, `alwaysLoad` and the enable toggle (`enableKey`).
53748
+ *
53749
+ * `alwaysLoad` (added in 3.16.2 alongside tool search) keeps a server's tools
53750
+ * loaded up front instead of deferring them behind tool search. ZCode's schema
53751
+ * types it as a boolean, so any other value is dropped with a warning.
52171
53752
  */
52172
53753
  function toZcodeCommonKeys({ serverName, config, enableKey, logger }) {
52173
53754
  const common = {};
@@ -52180,6 +53761,9 @@ function toZcodeCommonKeys({ serverName, config, enableKey, logger }) {
52180
53761
  const { protocolVersion } = config;
52181
53762
  if (ZCODE_PROTOCOL_VERSIONS.has(protocolVersion)) common.protocolVersion = protocolVersion;
52182
53763
  else if (protocolVersion !== void 0) logger?.warn(`ZCode MCP server "${serverName}": dropping protocolVersion ${JSON.stringify(protocolVersion)}, as ZCode accepts only ${[...ZCODE_PROTOCOL_VERSIONS].map((v) => JSON.stringify(v)).join(", ")}.`);
53764
+ const { alwaysLoad } = config;
53765
+ if (typeof alwaysLoad === "boolean") common.alwaysLoad = alwaysLoad;
53766
+ else if (alwaysLoad !== void 0) logger?.warn(`ZCode MCP server "${serverName}": dropping alwaysLoad ${JSON.stringify(alwaysLoad)}, as it must be a boolean.`);
52183
53767
  if (config.disabled === true) common[enableKey] = false;
52184
53768
  return common;
52185
53769
  }
@@ -52192,11 +53776,11 @@ function toZcodeCommonKeys({ serverName, config, enableKey, logger }) {
52192
53776
  * absent.
52193
53777
  *
52194
53778
  * Every server also carries `timeoutMs` (from the canonical `timeout`, or an
52195
- * explicit `timeoutMs`) and an allow-listed `protocolVersion`; stdio servers
52196
- * carry `cwd`, and remote servers an `oauth` built from the namespaced
52197
- * `zcodeOauth`. That key is stripped by `RulesyncMcp.getMcpServers()`, so it
53779
+ * explicit `timeoutMs`), an allow-listed `protocolVersion` and a boolean
53780
+ * `alwaysLoad`; stdio servers carry `cwd`, and remote servers an `oauth` built
53781
+ * from the namespaced `zcodeOauth`. That key is stripped by `RulesyncMcp.getMcpServers()`, so it
52198
53782
  * is read from the authored server through `rawServerOf`. ZCode's docs do not
52199
- * name these keys, so they follow the 3.14.3/3.15.3 runtime's per-server
53783
+ * name these keys, so they follow the 3.14.3–3.16.2 runtime's per-server
52200
53784
  * schema, which is strict: an unknown key makes ZCode skip the server, which
52201
53785
  * is why the output stays a whitelist.
52202
53786
  *
@@ -52291,8 +53875,8 @@ const ZCODE_ENABLE_KEYS = /* @__PURE__ */ new Set(["enable", "enabled"]);
52291
53875
  * and unknown keys pass through untouched so an import keeps whatever a
52292
53876
  * hand-authored entry declared. `timeoutMs` is lifted back into the canonical
52293
53877
  * `timeout` (milliseconds); when the server also states a `timeout`, ZCode
52294
- * has already dropped that one, so `timeoutMs` replaces it. A ZCode-shaped
52295
- * `oauth` is imported as the namespaced `zcodeOauth`, so its client secret is
53878
+ * has already dropped that one, so `timeoutMs` replaces it. Any record
53879
+ * `oauth` is imported as the namespaced `zcodeOauth`, so a client secret is
52296
53880
  * never emitted to another tool. Generation back out is a whitelist, so only
52297
53881
  * the keys `convertToZcodeFormat` writes are re-emitted.
52298
53882
  *
@@ -52318,7 +53902,7 @@ function convertFromZcodeFormat({ zcodeServers }) {
52318
53902
  continue;
52319
53903
  }
52320
53904
  if (key === "timeout" && typeof config.timeoutMs === "number") continue;
52321
- if (key === "oauth" && isZcodeOauth(value)) {
53905
+ if (key === "oauth" && isRecord$1(value)) {
52322
53906
  converted[ZCODE_OAUTH_CANONICAL_KEY] = value;
52323
53907
  continue;
52324
53908
  }
@@ -52964,6 +54548,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
52964
54548
  supportsDisabledTools: true
52965
54549
  }
52966
54550
  }],
54551
+ ["firebender", {
54552
+ class: FirebenderMcp,
54553
+ meta: {
54554
+ supportsProject: true,
54555
+ supportsGlobal: true,
54556
+ supportsEnabledTools: false,
54557
+ supportsDisabledTools: false
54558
+ }
54559
+ }],
52967
54560
  ["goose", {
52968
54561
  class: GooseMcp,
52969
54562
  meta: {
@@ -53234,6 +54827,15 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
53234
54827
  supportsDisabledTools: false
53235
54828
  }
53236
54829
  }],
54830
+ ["xum", {
54831
+ class: XumMcp,
54832
+ meta: {
54833
+ supportsProject: true,
54834
+ supportsGlobal: true,
54835
+ supportsEnabledTools: true,
54836
+ supportsDisabledTools: false
54837
+ }
54838
+ }],
53237
54839
  ["devin", {
53238
54840
  class: DevinMcp,
53239
54841
  meta: {
@@ -57315,6 +58917,11 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
57315
58917
  reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
57316
58918
  widens: isNotFalse
57317
58919
  },
58920
+ {
58921
+ path: ["allowNestedUserNamespaces"],
58922
+ reason: "lets sandboxed Linux commands create user namespaces, which gets around the sandbox's file protections",
58923
+ widens: isNotFalse
58924
+ },
57318
58925
  {
57319
58926
  path: ["allowUnsandboxedCommands"],
57320
58927
  reason: "controls whether Claude may retry a blocked command outside the sandbox",
@@ -67764,7 +69371,7 @@ const MANAGED_OMP_TOOLS = /* @__PURE__ */ new Set([
67764
69371
  * catch-all one at its own strictness, a pattern-specific one as `prompt`).
67765
69372
  *
67766
69373
  * The `omp.approvalMode` override writes `tools.approvalMode` (`always-ask`,
67767
- * `write` or `yolo`); without it the key is left as it is in the file, and on
69374
+ * `always-ask-cached`, `write` or `yolo`); without it the key is left as it is in the file, and on
67768
69375
  * import a recognized value is read back into the override.
67769
69376
  *
67770
69377
  * `config.yml` carries every other oh-my-pi setting, so only `tools.approval`
@@ -70036,6 +71643,8 @@ function buildReasonixPermissionEntry(toolName, pattern) {
70036
71643
  const REASONIX_TOOL_LABEL = "Reasonix";
70037
71644
  /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
70038
71645
  const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
71646
+ /** The `[permissions]` key the override's `askOnExternalOrigin` writes and reads. */
71647
+ const REASONIX_ASK_ON_EXTERNAL_ORIGIN_KEY = "ask_on_external_origin";
70039
71648
  /**
70040
71649
  * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
70041
71650
  * the permission policy: they take Bash out of its OS jail, open that jail to
@@ -70238,6 +71847,95 @@ function applyPermissionsMode({ permissions, authored, global, relativeFilePath,
70238
71847
  reason: "lets every writer call that no rule matches run without a prompt"
70239
71848
  }];
70240
71849
  }
71850
+ /**
71851
+ * Writes the override's `askOnExternalOrigin` into `[permissions]`. Reasonix
71852
+ * honors the key only in the user config (`narrowPermissions` in
71853
+ * `project_scope.go`), so a project-scope generate skips it with a warning and
71854
+ * leaves whatever the file had. Turning it on only adds prompts, so that is
71855
+ * quiet; turning off a value the file had on is reported, since Auto and YOLO
71856
+ * then run calls that repeat text from a web page or tool result without asking.
71857
+ * As with `applyAllowDynamicBash`, only a literal `true` counts as quiet, so a
71858
+ * value a `--no-validate` run let through is never written in silence.
71859
+ *
71860
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.47.1/docs/GUIDE.md
71861
+ */
71862
+ function applyAskOnExternalOrigin({ permissions, override, global, relativeFilePath, logger }) {
71863
+ const authored = override?.askOnExternalOrigin;
71864
+ if (authored === void 0) return [];
71865
+ if (!global) {
71866
+ logger?.warn(`Reasonix permissions: skipping reasonix.askOnExternalOrigin for the project-scoped ${relativeFilePath}; Reasonix honors [permissions] ask_on_external_origin only in the user config and ignores a project value, so generate it with --global instead.`);
71867
+ return [];
71868
+ }
71869
+ const previous = permissions[REASONIX_ASK_ON_EXTERNAL_ORIGIN_KEY];
71870
+ permissions[REASONIX_ASK_ON_EXTERNAL_ORIGIN_KEY] = authored;
71871
+ if (authored === true || previous !== true && authored === false) return [];
71872
+ return [{
71873
+ label: `permissions.${REASONIX_ASK_ON_EXTERNAL_ORIGIN_KEY}`,
71874
+ reason: "lets Auto and YOLO run a call that repeats text from a web page or tool result without asking first"
71875
+ }];
71876
+ }
71877
+ /**
71878
+ * The `tools` patch entry — the `[tools]` table with the override's `disabled`
71879
+ * list laid over it, or nothing when the override does not author one — and the
71880
+ * trust rows it carries. Adding a name only removes a tool, and a project file
71881
+ * can only add to the user's list, so that stays quiet. The list is replaced
71882
+ * whole, though, so a name the file had turned off and the override leaves out
71883
+ * is turned back on, and that is reported, as are a `[tools]` or a `disabled`
71884
+ * the file holds in a shape that cannot be read. Only `disabled` is written:
71885
+ * the rest of `[tools]` (timeouts, search backends, ...) stays as it is.
71886
+ */
71887
+ function applyToolsDisabled({ existing, override }) {
71888
+ const disabled = override?.tools?.disabled;
71889
+ if (disabled === void 0) return {
71890
+ patch: {},
71891
+ entries: []
71892
+ };
71893
+ const patch = { tools: {
71894
+ ...asReasonixRecord(existing),
71895
+ disabled
71896
+ } };
71897
+ if (existing === void 0) return {
71898
+ patch,
71899
+ entries: []
71900
+ };
71901
+ if (!isRecord$1(existing)) return {
71902
+ patch,
71903
+ entries: [{
71904
+ label: "tools",
71905
+ reason: replacedUnreadableReason({
71906
+ shape: "object",
71907
+ toolLabel: REASONIX_TOOL_LABEL
71908
+ })
71909
+ }]
71910
+ };
71911
+ const before = existing.disabled;
71912
+ if (before === void 0) return {
71913
+ patch,
71914
+ entries: []
71915
+ };
71916
+ if (!Array.isArray(before)) return {
71917
+ patch,
71918
+ entries: [{
71919
+ label: "tools.disabled",
71920
+ reason: replacedUnreadableReason({
71921
+ shape: "list",
71922
+ toolLabel: REASONIX_TOOL_LABEL
71923
+ })
71924
+ }]
71925
+ };
71926
+ const reEnabled = (0, es_toolkit.uniq)(before.filter((name) => !disabled.includes(name)));
71927
+ if (reEnabled.length === 0) return {
71928
+ patch,
71929
+ entries: []
71930
+ };
71931
+ return {
71932
+ patch,
71933
+ entries: [{
71934
+ label: "tools.disabled",
71935
+ reason: `turns back on ${reEnabled.map((name) => quoteValueForWarning(name)).join(", ")}, which the file had turned off`
71936
+ }]
71937
+ };
71938
+ }
70241
71939
  function parseReasonixConfig(fileContent) {
70242
71940
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
70243
71941
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -70293,6 +71991,11 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
70293
71991
  });
70294
71992
  }
70295
71993
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
71994
+ await warnIfReasonixNestedProjectConfigShadowed({
71995
+ outputRoot,
71996
+ global,
71997
+ logger
71998
+ });
70296
71999
  const paths = this.getSettablePaths({ global });
70297
72000
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
70298
72001
  const relativeFilePathForLog = toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath));
@@ -70352,7 +72055,20 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
70352
72055
  relativeFilePath: relativeFilePathForLog,
70353
72056
  logger
70354
72057
  }));
72058
+ trustAffecting.push(...applyAskOnExternalOrigin({
72059
+ permissions: mergedPermissions,
72060
+ override,
72061
+ global,
72062
+ relativeFilePath: relativeFilePathForLog,
72063
+ logger
72064
+ }));
70355
72065
  const patch = { permissions: mergedPermissions };
72066
+ const toolsDisabled = applyToolsDisabled({
72067
+ existing: parsed.tools,
72068
+ override
72069
+ });
72070
+ Object.assign(patch, toolsDisabled.patch);
72071
+ trustAffecting.push(...toolsDisabled.entries);
70356
72072
  if (override?.sandbox !== void 0) {
70357
72073
  const authoredSandbox = dropUserOnlySandboxKeys({
70358
72074
  sandbox: override.sandbox,
@@ -70426,6 +72142,14 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
70426
72142
  const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
70427
72143
  if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
70428
72144
  if (this.global && typeof permissions.mode === "string" && REASONIX_PERMISSION_MODES.has(permissions.mode)) reasonixOverride.mode = permissions.mode;
72145
+ const askOnExternalOrigin = permissions[REASONIX_ASK_ON_EXTERNAL_ORIGIN_KEY];
72146
+ if (this.global && typeof askOnExternalOrigin === "boolean") reasonixOverride.askOnExternalOrigin = askOnExternalOrigin;
72147
+ const toolsDisabled = asReasonixRecord(this.toml.tools).disabled;
72148
+ if (Array.isArray(toolsDisabled)) {
72149
+ const names = toReasonixStringArray(toolsDisabled);
72150
+ if (names.length < toolsDisabled.length) warnWithFallback(void 0, `Reasonix permissions: ${this.getRelativePathFromCwd()} [tools] disabled holds ${toolsDisabled.length - names.length} non-string entries; they are not imported into reasonix.tools.disabled, so the next generate drops them.`);
72151
+ reasonixOverride.tools = { disabled: names };
72152
+ }
70429
72153
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
70430
72154
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
70431
72155
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;
@@ -72007,7 +73731,12 @@ var TaktPermissions = class TaktPermissions extends ToolPermissions {
72007
73731
  const authoredProviderOptions = isPlainObject$1(override?.[TAKT_PROVIDER_OPTIONS_KEY]) ? override[TAKT_PROVIDER_OPTIONS_KEY] : void 0;
72008
73732
  const runtimeModeRefusesProviderOptions = authoredProviderOptions !== void 0 && runtime.active;
72009
73733
  if (runtimeModeRefusesProviderOptions) logger?.warn(`Takt permissions: not writing "${TAKT_PROVIDER_OPTIONS_KEY}" to ${filePath} because ${runtime.filePaths.join(" + ")} puts Takt in runtime provider mode (Takt 0.56.0+), where any legacy provider setting in config.yaml makes Takt fail with "Mixed provider configuration detected". Move those options to \`provider.profiles.<profile>.options\` in runtime.yaml, and remove them from the \`takt\` block of the rulesync source. A "${TAKT_PROVIDER_OPTIONS_KEY}" already in ${filePath} is left untouched — rulesync does not own that key.`);
72010
- const overrideProviderOptions = runtimeModeRefusesProviderOptions ? void 0 : authoredProviderOptions;
73734
+ const overrideProviderOptions = runtimeModeRefusesProviderOptions ? void 0 : dropUntrustedProviderOptions({
73735
+ providerOptions: authoredProviderOptions,
73736
+ global,
73737
+ filePath,
73738
+ logger
73739
+ });
72011
73740
  const authoredPolicies = pickSecurityPolicies(override, {
72012
73741
  filePath,
72013
73742
  logger,
@@ -72511,6 +74240,68 @@ function taktModeToRulesyncConfig(mode) {
72511
74240
  default: return { permission: { bash: { [CATCH_ALL_PATTERN$1]: "deny" } } };
72512
74241
  }
72513
74242
  }
74243
+ const TAKT_BASE_URL_PROVIDERS = [
74244
+ "codex",
74245
+ "claude",
74246
+ "deepseek_harness"
74247
+ ];
74248
+ const TAKT_DEEPSEEK_HARNESS_KEY = "deepseek_harness";
74249
+ /** Mirrors Takt's `isLoopbackBaseUrl`: localhost, *.localhost, ::1 or 127.x.x.x. */
74250
+ function isLoopbackBaseUrl(value) {
74251
+ let hostname;
74252
+ try {
74253
+ hostname = new URL(value).hostname.toLowerCase();
74254
+ } catch {
74255
+ return false;
74256
+ }
74257
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "::1" || hostname === "[::1]") return true;
74258
+ const octets = hostname.split(".");
74259
+ return octets.length === 4 && octets[0] === "127" && octets.every((octet) => /^\d+$/.test(octet) && Number(octet) <= 255);
74260
+ }
74261
+ /** The keys of one `provider_options.<provider>` entry Takt rejects in config.yaml. */
74262
+ function untrustedProviderOptionKeys({ provider, options, global }) {
74263
+ const keys = [];
74264
+ if (provider === TAKT_DEEPSEEK_HARNESS_KEY) {
74265
+ if (options.reasoning_effort !== void 0) keys.push("reasoning_effort");
74266
+ const { plugins } = options;
74267
+ if (!global && plugins !== void 0 && (!Array.isArray(plugins) || plugins.length > 0)) keys.push("plugins");
74268
+ }
74269
+ const baseUrl = options.base_url;
74270
+ if (!global && TAKT_BASE_URL_PROVIDERS.includes(provider) && baseUrl !== void 0 && (typeof baseUrl !== "string" || !isLoopbackBaseUrl(baseUrl))) keys.push("base_url");
74271
+ return keys;
74272
+ }
74273
+ /**
74274
+ * Drop the `provider_options` entries Takt refuses to load from `config.yaml`
74275
+ * (it fails at startup with a `Configuration error`), warning for each:
74276
+ * - `deepseek_harness.reasoning_effort` in either scope — Takt accepts it only
74277
+ * in runtime profile options or `TAKT_PROVIDER_OPTIONS_DEEPSEEK_HARNESS_REASONING_EFFORT`.
74278
+ * - at project scope, a non-empty `deepseek_harness.plugins` (Takt 0.71.0+,
74279
+ * global config only) and a non-loopback `{codex,claude,deepseek_harness}.base_url`.
74280
+ * https://github.com/nrslib/takt/blob/main/src/infra/config/providerOptions.ts
74281
+ * https://github.com/nrslib/takt/blob/main/docs/configuration.md
74282
+ */
74283
+ function dropUntrustedProviderOptions({ providerOptions, global, filePath, logger }) {
74284
+ if (providerOptions === void 0) return;
74285
+ const dropped = [];
74286
+ const result = {};
74287
+ for (const [provider, options] of Object.entries(providerOptions)) {
74288
+ if (!isPlainObject$1(options)) {
74289
+ result[provider] = options;
74290
+ continue;
74291
+ }
74292
+ const untrusted = untrustedProviderOptionKeys({
74293
+ provider,
74294
+ options,
74295
+ global
74296
+ });
74297
+ const kept = Object.fromEntries(Object.entries(options).filter(([key]) => !untrusted.includes(key)));
74298
+ dropped.push(...untrusted.map((key) => `${TAKT_PROVIDER_OPTIONS_KEY}.${provider}.${key}`));
74299
+ if (Object.keys(kept).length > 0 || Object.keys(options).length === 0) result[provider] = kept;
74300
+ }
74301
+ if (dropped.length === 0) return providerOptions;
74302
+ logger?.warn(`Takt permissions: not writing ${dropped.map((key) => `"${key}"`).join(", ")} to ${filePath} because Takt refuses to load ${dropped.length === 1 ? "it" : "them"} from ${global ? "config.yaml" : "a project config.yaml"} and fails at startup with a "Configuration error". Set non-loopback base URLs and DeepSeek Harness plugins in the global ~/.takt/config.yaml (or the TAKT_PROVIDER_OPTIONS_* environment variables), and DeepSeek Harness reasoning_effort in a runtime.yaml profile's options or TAKT_PROVIDER_OPTIONS_DEEPSEEK_HARNESS_REASONING_EFFORT, then remove them from the \`takt\` block of the rulesync source.`);
74303
+ return Object.keys(result).length > 0 ? result : void 0;
74304
+ }
72514
74305
  /**
72515
74306
  * Lift Takt's security-policy keys out of a source object, keeping only the
72516
74307
  * shapes Takt itself accepts (a boolean, or a table of booleans). Used in both
@@ -76445,6 +78236,11 @@ const TOOL_DISPLAY = [
76445
78236
  label: "Factory Droid",
76446
78237
  group: "ai"
76447
78238
  },
78239
+ {
78240
+ key: "firebender",
78241
+ label: "Firebender",
78242
+ group: "ai"
78243
+ },
76448
78244
  {
76449
78245
  key: "opencode",
76450
78246
  label: "OpenCode",
@@ -76600,6 +78396,11 @@ const TOOL_DISPLAY = [
76600
78396
  label: "JetBrains AI Assistant",
76601
78397
  group: "ai"
76602
78398
  },
78399
+ {
78400
+ key: "androidstudio",
78401
+ label: "Gemini in Android Studio",
78402
+ group: "ai"
78403
+ },
76603
78404
  {
76604
78405
  key: "junie",
76605
78406
  label: "JetBrains Junie",
@@ -76635,6 +78436,11 @@ const TOOL_DISPLAY = [
76635
78436
  label: "Warp Agent CLI",
76636
78437
  group: "ai"
76637
78438
  },
78439
+ {
78440
+ key: "xum",
78441
+ label: "Xum",
78442
+ group: "ai"
78443
+ },
76638
78444
  {
76639
78445
  key: "replit",
76640
78446
  label: "Replit",
@@ -77779,6 +79585,27 @@ var AmpSkill = class AmpSkill extends ToolSkill {
77779
79585
  }
77780
79586
  };
77781
79587
  //#endregion
79588
+ //#region src/features/skills/androidstudio-skill.ts
79589
+ /**
79590
+ * Gemini in Android Studio skills follow the Agent Skills open standard
79591
+ * (`<name>/SKILL.md` with `name` and `description` frontmatter), so this target
79592
+ * reuses {@link AgentsSkillsSkill} with Android Studio's own location:
79593
+ * `.android-studio/skills/` at the project root (project) or in the home
79594
+ * directory (global). The shared `.agents/skills/` location Android Studio also
79595
+ * reads belongs to the `agentsskills` target.
79596
+ *
79597
+ * @see https://developer.android.com/studio/gemini/skills
79598
+ */
79599
+ var AndroidstudioSkill = class extends AgentsSkillsSkill {
79600
+ static getSettablePaths(_options = {}) {
79601
+ return { relativeDirPath: ANDROIDSTUDIO_SKILLS_DIR_PATH };
79602
+ }
79603
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
79604
+ const targets = rulesyncSkill.getFrontmatter().targets;
79605
+ return targets.includes("*") || targets.includes("androidstudio");
79606
+ }
79607
+ };
79608
+ //#endregion
77782
79609
  //#region src/features/skills/antigravity-plugin-skill.ts
77783
79610
  var AntigravityPluginSkill = class extends AntigravityIdeSkill {
77784
79611
  static isTargetedByRulesyncSkill(rulesyncSkill) {
@@ -81247,6 +83074,26 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
81247
83074
  }
81248
83075
  };
81249
83076
  //#endregion
83077
+ //#region src/features/skills/firebender-skill.ts
83078
+ /**
83079
+ * Firebender skills follow the Agent Skills layout (`<name>/SKILL.md` with the
83080
+ * required `name` and `description` frontmatter, plus optional supporting
83081
+ * files), so this target reuses {@link AgentsSkillsSkill} with Firebender's
83082
+ * recommended locations: `.firebender/skills/` (project) and
83083
+ * `~/.firebender/skills/` (user).
83084
+ *
83085
+ * @see https://docs.firebender.com/api-reference/skills
83086
+ */
83087
+ var FirebenderSkill = class extends AgentsSkillsSkill {
83088
+ static getSettablePaths(_options = {}) {
83089
+ return { relativeDirPath: FIREBENDER_SKILLS_DIR_PATH };
83090
+ }
83091
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
83092
+ const targets = rulesyncSkill.getFrontmatter().targets;
83093
+ return targets.includes("*") || targets.includes("firebender");
83094
+ }
83095
+ };
83096
+ //#endregion
81250
83097
  //#region src/features/skills/gitlabduo-skill.ts
81251
83098
  /**
81252
83099
  * GitLab Duo skills follow the Agent Skills standard (`name` and `description`
@@ -84782,6 +86629,110 @@ var WarpSkill = class WarpSkill extends ToolSkill {
84782
86629
  }
84783
86630
  };
84784
86631
  //#endregion
86632
+ //#region src/features/skills/xum-skill.ts
86633
+ /**
86634
+ * The Agent Skills standard keys the `agentsskills` section carries. On import
86635
+ * they go back there, exactly as for the `agentsskills` target, and every other
86636
+ * key besides `name` / `description` goes into the `xum` section.
86637
+ */
86638
+ const AGENT_SKILLS_STANDARD_KEYS = /* @__PURE__ */ new Set([
86639
+ "name",
86640
+ "description",
86641
+ "license",
86642
+ "compatibility",
86643
+ "metadata",
86644
+ "allowed-tools"
86645
+ ]);
86646
+ /**
86647
+ * Xum skills follow the Agent Skills layout (`<name>/SKILL.md` with the
86648
+ * required `name` and `description` frontmatter; the directory name is
86649
+ * lowercase letters, digits and single hyphens up to 64 characters, and
86650
+ * `name` must match it), so this target reuses {@link AgentsSkillsSkill} with
86651
+ * Xum's locations: `.xum/skills/` (project) and `~/.xum/skills/` (user).
86652
+ * Within one directory `.xum/skills` wins over `.agents/skills`.
86653
+ *
86654
+ * On top of the standard fields, Xum reads `disable-model-invocation` (and its
86655
+ * own `advertise: false` alias), `user-invocable`, `argument-hint`,
86656
+ * `when_to_use` / `when-to-use` and `paths` (globs that make the skill
86657
+ * path-scoped). Those come from the `xum` section of the rulesync skill and are
86658
+ * written verbatim; the two invocation gates fall back to the root-level
86659
+ * rulesync values.
86660
+ *
86661
+ * @see https://xum.cdr.dev/agents/agent-skills
86662
+ */
86663
+ var XumSkill = class XumSkill extends AgentsSkillsSkill {
86664
+ static getSettablePaths(_options = {}) {
86665
+ return { relativeDirPath: XUM_SKILLS_DIR_PATH };
86666
+ }
86667
+ toRulesyncSkill() {
86668
+ const base = super.toRulesyncSkill();
86669
+ const xumSection = Object.fromEntries(Object.entries(this.getFrontmatter()).filter(([key]) => !AGENT_SKILLS_STANDARD_KEYS.has(key)));
86670
+ if (Object.keys(xumSection).length === 0) return base;
86671
+ const rulesyncFrontmatter = {
86672
+ ...base.getFrontmatter(),
86673
+ xum: xumSection
86674
+ };
86675
+ return new RulesyncSkill({
86676
+ outputRoot: base.getOutputRoot(),
86677
+ relativeDirPath: base.getRelativeDirPath(),
86678
+ dirName: base.getDirName(),
86679
+ frontmatter: rulesyncFrontmatter,
86680
+ body: base.getBody(),
86681
+ otherFiles: base.getOtherFiles(),
86682
+ validate: true,
86683
+ global: this.global
86684
+ });
86685
+ }
86686
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
86687
+ const settablePaths = XumSkill.getSettablePaths({ global });
86688
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
86689
+ const dirName = rulesyncSkill.getDirName();
86690
+ const xumSection = {};
86691
+ const ignoredKeys = [];
86692
+ for (const [key, value] of Object.entries(rulesyncFrontmatter.xum ?? {})) if (AGENT_SKILLS_STANDARD_KEYS.has(key)) ignoredKeys.push(key);
86693
+ else xumSection[key] = value;
86694
+ if (ignoredKeys.length > 0) warnWithFallback(logger, `${(0, node_path.join)(outputRoot, settablePaths.relativeDirPath, dirName, SKILL_FILE_NAME)}: the xum section sets ${ignoredKeys.map(quoteValueForWarning).join(", ")}, which ${ignoredKeys.length === 1 ? "is" : "are"} ignored there; set name/description at the root and the Agent Skills standard fields at the root or in the agentsskills section.`);
86695
+ const userInvocable = resolveUserInvocable({
86696
+ rootFrontmatter: rulesyncFrontmatter,
86697
+ section: xumSection
86698
+ });
86699
+ const disableModelInvocation = resolveDisableModelInvocation({
86700
+ rootFrontmatter: rulesyncFrontmatter,
86701
+ section: xumSection
86702
+ });
86703
+ const frontmatter = {
86704
+ name: rulesyncFrontmatter.name,
86705
+ description: rulesyncFrontmatter.description,
86706
+ ...toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(rulesyncFrontmatter)),
86707
+ ...xumSection,
86708
+ ...userInvocable !== void 0 && { "user-invocable": userInvocable },
86709
+ ...disableModelInvocation !== void 0 && { "disable-model-invocation": disableModelInvocation }
86710
+ };
86711
+ AgentsSkillsSkill.reportSpecViolations({
86712
+ outputRoot,
86713
+ relativeDirPath: settablePaths.relativeDirPath,
86714
+ dirName,
86715
+ frontmatter,
86716
+ sourceAllowedTools: rulesyncFrontmatter.agentsskills?.["allowed-tools"],
86717
+ logger
86718
+ });
86719
+ return new XumSkill({
86720
+ outputRoot,
86721
+ relativeDirPath: settablePaths.relativeDirPath,
86722
+ dirName,
86723
+ frontmatter,
86724
+ body: rulesyncSkill.getBody(),
86725
+ otherFiles: rulesyncSkill.getOtherFiles(),
86726
+ validate,
86727
+ global
86728
+ });
86729
+ }
86730
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
86731
+ const targets = rulesyncSkill.getFrontmatter().targets;
86732
+ return targets.includes("*") || targets.includes("xum");
86733
+ }
86734
+ };
86735
+ //#endregion
84785
86736
  //#region src/features/skills/zcode-skill.ts
84786
86737
  const ZcodeSkillFrontmatterSchema = zod_mini.z.looseObject({
84787
86738
  name: zod_mini.z.string(),
@@ -85197,6 +87148,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
85197
87148
  supportsGlobal: false
85198
87149
  }
85199
87150
  }],
87151
+ ["androidstudio", {
87152
+ class: AndroidstudioSkill,
87153
+ meta: {
87154
+ supportsProject: true,
87155
+ supportsSimulated: false,
87156
+ supportsGlobal: true
87157
+ }
87158
+ }],
85200
87159
  ["amp", {
85201
87160
  class: AmpSkill,
85202
87161
  meta: {
@@ -85382,6 +87341,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
85382
87341
  supportsGlobal: true
85383
87342
  }
85384
87343
  }],
87344
+ ["firebender", {
87345
+ class: FirebenderSkill,
87346
+ meta: {
87347
+ supportsProject: true,
87348
+ supportsSimulated: false,
87349
+ supportsGlobal: true
87350
+ }
87351
+ }],
85385
87352
  ["goose", {
85386
87353
  class: GooseSkill,
85387
87354
  meta: {
@@ -85632,6 +87599,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
85632
87599
  supportsGlobal: true
85633
87600
  }
85634
87601
  }],
87602
+ ["xum", {
87603
+ class: XumSkill,
87604
+ meta: {
87605
+ supportsProject: true,
87606
+ supportsSimulated: false,
87607
+ supportsGlobal: true
87608
+ }
87609
+ }],
85635
87610
  ["devin", {
85636
87611
  class: DevinSkill,
85637
87612
  meta: {
@@ -89478,6 +91453,338 @@ var FactorydroidSubagent = class FactorydroidSubagent extends ToolSubagent {
89478
91453
  }
89479
91454
  };
89480
91455
  //#endregion
91456
+ //#region src/features/subagents/firebender-subagent.ts
91457
+ /**
91458
+ * Frontmatter of a Firebender agent file. `tools` is a comma-separated string
91459
+ * of tool groups (`read`, `edit`, `execution`, `other`) and MCP tools
91460
+ * (`mcp_<server>_<tool>`); `model` is `small`, `medium`, `large`, `inherit` or a
91461
+ * model ID; `callable` lets the main agent delegate to it. Unknown keys are
91462
+ * preserved.
91463
+ *
91464
+ * @see https://docs.firebender.com/api-reference/agents
91465
+ */
91466
+ const FirebenderSubagentFrontmatterSchema = zod_mini.z.looseObject({
91467
+ name: zod_mini.z.optional(zod_mini.z.string()),
91468
+ description: zod_mini.z.optional(zod_mini.z.string()),
91469
+ color: zod_mini.z.optional(zod_mini.z.string()),
91470
+ icon: zod_mini.z.optional(zod_mini.z.string()),
91471
+ tools: zod_mini.z.optional(zod_mini.z.string()),
91472
+ model: zod_mini.z.optional(zod_mini.z.string()),
91473
+ callable: zod_mini.z.optional(zod_mini.z.boolean())
91474
+ });
91475
+ /**
91476
+ * The `agents` entry Firebender resolves to an agent file rulesync writes:
91477
+ * relative to the project root for the project config, and to `~/.firebender`
91478
+ * for the personal one.
91479
+ */
91480
+ function toAgentsEntry({ relativeFilePath, global }) {
91481
+ const fileName = toPosixPath(relativeFilePath);
91482
+ return global ? node_path.posix.join(FIREBENDER_AGENTS_DIR_NAME, fileName) : node_path.posix.join(toPosixPath(FIREBENDER_AGENTS_DIR_PATH), fileName);
91483
+ }
91484
+ /**
91485
+ * The agent file name an existing `agents` entry points at when it lies
91486
+ * directly inside the directory rulesync writes agents to
91487
+ * (`.firebender/agents/` in the project; `agents/` or `~/.firebender/agents/`
91488
+ * in the personal config), or `undefined` for any other entry
91489
+ * (`~/firebender-agents/...`, an absolute path, a nested path).
91490
+ */
91491
+ function managedAgentFileName({ entry, global }) {
91492
+ const normalized = toPosixPath(entry).replace(/^\.\//, "");
91493
+ const prefix = global ? `${FIREBENDER_AGENTS_DIR_NAME}/` : `${toPosixPath(FIREBENDER_AGENTS_DIR_PATH)}/`;
91494
+ const homePrefix = `~/${toPosixPath(FIREBENDER_AGENTS_DIR_PATH)}/`;
91495
+ const rest = normalized.startsWith(prefix) ? normalized.slice(prefix.length) : global && normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : void 0;
91496
+ return rest !== void 0 && rest.endsWith(".md") && !rest.includes("/") ? rest : void 0;
91497
+ }
91498
+ /**
91499
+ * Subagent generator for Firebender.
91500
+ *
91501
+ * Each agent is a Markdown file with YAML frontmatter, written to
91502
+ * `.firebender/agents/<name>.md` (project) or `~/.firebender/agents/<name>.md`
91503
+ * (global). Firebender only loads agents listed in the `agents` array of
91504
+ * `firebender.json`, so generation also registers every file there — see
91505
+ * {@link FirebenderAgentsRegistry}. Rulesync subagents are meant to be
91506
+ * delegated to, so `callable: true` is written unless the `firebender:`
91507
+ * section says otherwise.
91508
+ *
91509
+ * @see https://docs.firebender.com/api-reference/agents
91510
+ */
91511
+ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
91512
+ frontmatter;
91513
+ body;
91514
+ constructor({ frontmatter, body, ...rest }) {
91515
+ if (rest.validate !== false) {
91516
+ const result = FirebenderSubagentFrontmatterSchema.safeParse(frontmatter);
91517
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
91518
+ }
91519
+ super({ ...rest });
91520
+ this.frontmatter = frontmatter;
91521
+ this.body = body;
91522
+ }
91523
+ static getSettablePaths(_options = {}) {
91524
+ return { relativeDirPath: FIREBENDER_AGENTS_DIR_PATH };
91525
+ }
91526
+ /**
91527
+ * Generation also read-modify-writes the `agents` key of `firebender.json`,
91528
+ * so the write is declared for the shared-file order derivation.
91529
+ */
91530
+ static getExtraSharedWritePaths({ global = false } = {}) {
91531
+ return [{
91532
+ relativeDirPath: getFirebenderConfigDir({ global }),
91533
+ relativeFilePath: FIREBENDER_CONFIG_FILE_NAME
91534
+ }];
91535
+ }
91536
+ getFrontmatter() {
91537
+ return this.frontmatter;
91538
+ }
91539
+ getBody() {
91540
+ return this.body;
91541
+ }
91542
+ toRulesyncSubagent() {
91543
+ const { name, description, callable, ...rest } = this.frontmatter;
91544
+ const firebenderSection = {
91545
+ ...rest,
91546
+ ...callable !== true && { callable: callable ?? false }
91547
+ };
91548
+ return new RulesyncSubagent({
91549
+ outputRoot: ".",
91550
+ frontmatter: {
91551
+ targets: ["*"],
91552
+ name: name ?? (0, node_path.basename)(this.getRelativeFilePath(), ".md"),
91553
+ description,
91554
+ ...Object.keys(firebenderSection).length > 0 && { firebender: firebenderSection }
91555
+ },
91556
+ body: this.body,
91557
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
91558
+ relativeFilePath: this.getRelativeFilePath(),
91559
+ validate: true
91560
+ });
91561
+ }
91562
+ /**
91563
+ * Every targeted subagent becomes its own agent file, plus one
91564
+ * {@link FirebenderAgentsRegistry} that lists them in `firebender.json`.
91565
+ * The registry is emitted even when no subagent is targeted, so agents a
91566
+ * previous run registered are retracted once their files are gone. The
91567
+ * agent directory is listed up front so the registry can tell a registered
91568
+ * file that still exists (kept, whoever wrote it) from a stale one.
91569
+ */
91570
+ static async fromRulesyncSubagents({ outputRoot = process.cwd(), rulesyncSubagents, global = false, logger, deleteOrphans = false }) {
91571
+ const agentsDirPath = this.getSettablePaths({ global }).relativeDirPath;
91572
+ const agentsDirEscapesRoot = await writablePathEscapesRoot({
91573
+ rootPath: outputRoot,
91574
+ targetPath: (0, node_path.join)(outputRoot, agentsDirPath, "agent.md")
91575
+ });
91576
+ const existingAgentFileNames = new Set(agentsDirEscapesRoot ? [] : await listDirectoryEntryNames((0, node_path.join)(outputRoot, agentsDirPath)));
91577
+ const subagents = rulesyncSubagents.map((rulesyncSubagent) => this.fromRulesyncSubagent({
91578
+ outputRoot,
91579
+ relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
91580
+ rulesyncSubagent,
91581
+ global
91582
+ }));
91583
+ const registry = new FirebenderAgentsRegistry({
91584
+ outputRoot,
91585
+ relativeDirPath: getFirebenderConfigDir({ global }),
91586
+ relativeFilePath: FIREBENDER_CONFIG_FILE_NAME,
91587
+ entries: subagents.map((subagent) => toAgentsEntry({
91588
+ relativeFilePath: subagent.getRelativeFilePath(),
91589
+ global
91590
+ })),
91591
+ existingAgentFileNames,
91592
+ deleteOrphans,
91593
+ frozen: agentsDirEscapesRoot,
91594
+ global,
91595
+ logger
91596
+ });
91597
+ return [...subagents, registry];
91598
+ }
91599
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
91600
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
91601
+ const firebenderSection = rulesyncFrontmatter.firebender ?? {};
91602
+ const frontmatter = {
91603
+ name: rulesyncFrontmatter.name,
91604
+ ...rulesyncFrontmatter.description !== void 0 && { description: rulesyncFrontmatter.description },
91605
+ callable: true,
91606
+ ...firebenderSection
91607
+ };
91608
+ const body = rulesyncSubagent.getBody();
91609
+ return new FirebenderSubagent({
91610
+ outputRoot,
91611
+ frontmatter,
91612
+ body,
91613
+ relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
91614
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
91615
+ fileContent: stringifyFrontmatter(body, frontmatter, { avoidBlockScalars: true }),
91616
+ validate,
91617
+ global
91618
+ });
91619
+ }
91620
+ validate() {
91621
+ if (!this.frontmatter) return {
91622
+ success: true,
91623
+ error: null
91624
+ };
91625
+ const result = FirebenderSubagentFrontmatterSchema.safeParse(this.frontmatter);
91626
+ if (result.success) return {
91627
+ success: true,
91628
+ error: null
91629
+ };
91630
+ return {
91631
+ success: false,
91632
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
91633
+ };
91634
+ }
91635
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
91636
+ return this.isTargetedByRulesyncSubagentDefault({
91637
+ rulesyncSubagent,
91638
+ toolTarget: "firebender"
91639
+ });
91640
+ }
91641
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
91642
+ const paths = this.getSettablePaths({ global });
91643
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
91644
+ const fileContent = await readFileContent(filePath);
91645
+ const { frontmatter, body } = parseFrontmatter(fileContent, filePath);
91646
+ const result = FirebenderSubagentFrontmatterSchema.safeParse(frontmatter);
91647
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
91648
+ return new FirebenderSubagent({
91649
+ outputRoot,
91650
+ relativeDirPath: paths.relativeDirPath,
91651
+ relativeFilePath,
91652
+ frontmatter: result.data,
91653
+ body: body.trim(),
91654
+ fileContent,
91655
+ validate,
91656
+ global
91657
+ });
91658
+ }
91659
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
91660
+ return new FirebenderSubagent({
91661
+ outputRoot,
91662
+ relativeDirPath,
91663
+ relativeFilePath,
91664
+ frontmatter: {},
91665
+ body: "",
91666
+ fileContent: "",
91667
+ validate: false
91668
+ });
91669
+ }
91670
+ };
91671
+ /**
91672
+ * The `agents` array of `firebender.json`, which Firebender requires to load
91673
+ * an agent file. Every generated agent is registered. An existing entry that
91674
+ * points into rulesync's agent directory is kept while its file exists (a
91675
+ * hand-written agent there stays registered) and dropped, with a warning, once
91676
+ * the file is gone. Under `--delete` the orphan sweep removes every agent file
91677
+ * there that this run does not generate, but only after this file is written,
91678
+ * so in that mode such entries are dropped here too and a single run
91679
+ * converges. Every other entry and every other key of the file is kept.
91680
+ * The array is merged into the file on disk at write time and the file itself
91681
+ * is never deleted.
91682
+ */
91683
+ var FirebenderAgentsRegistry = class extends ToolSubagent {
91684
+ entries;
91685
+ existingAgentFileNames;
91686
+ deleteOrphans;
91687
+ frozen;
91688
+ logger;
91689
+ mergedContent = null;
91690
+ constructor({ entries, existingAgentFileNames = /* @__PURE__ */ new Set(), deleteOrphans = false, frozen = false, logger, ...rest }) {
91691
+ super({
91692
+ ...rest,
91693
+ fileContent: ""
91694
+ });
91695
+ this.entries = entries;
91696
+ this.existingAgentFileNames = existingAgentFileNames;
91697
+ this.deleteOrphans = deleteOrphans;
91698
+ this.frozen = frozen;
91699
+ this.logger = logger;
91700
+ }
91701
+ getEntries() {
91702
+ return this.entries;
91703
+ }
91704
+ isDeletable() {
91705
+ return false;
91706
+ }
91707
+ shouldMergeExistingFileContent() {
91708
+ return true;
91709
+ }
91710
+ setFileContent(newFileContent) {
91711
+ this.mergedContent = this.mergeInto(newFileContent);
91712
+ }
91713
+ getFileContent() {
91714
+ if (this.mergedContent === null) this.mergedContent = this.mergeInto("");
91715
+ return this.mergedContent;
91716
+ }
91717
+ mergeInto(existingContent) {
91718
+ const paths = {
91719
+ relativeDirPath: this.getRelativeDirPath(),
91720
+ relativeFilePath: this.getRelativeFilePath()
91721
+ };
91722
+ const filePath = (0, node_path.join)(this.getOutputRoot(), paths.relativeDirPath, paths.relativeFilePath);
91723
+ const existing = parseSharedConfig({
91724
+ format: "json",
91725
+ fileContent: existingContent,
91726
+ filePath,
91727
+ invalidRootPolicy: "error"
91728
+ });
91729
+ if (this.frozen) return applySharedConfigPatch({
91730
+ fileKey: sharedConfigFileKey(paths),
91731
+ feature: "subagents",
91732
+ existingContent,
91733
+ patch: { agents: existing.agents },
91734
+ filePath,
91735
+ logger: this.logger
91736
+ });
91737
+ const existingEntries = Array.isArray(existing.agents) ? existing.agents : [];
91738
+ const generatedByFileName = new Map(this.entries.map((entry) => [managedAgentFileName({
91739
+ entry,
91740
+ global: this.global
91741
+ }) ?? entry, entry]));
91742
+ const agents = [];
91743
+ const placed = /* @__PURE__ */ new Set();
91744
+ const dropped = [];
91745
+ for (const entry of existingEntries) {
91746
+ const fileName = typeof entry === "string" ? managedAgentFileName({
91747
+ entry,
91748
+ global: this.global
91749
+ }) : void 0;
91750
+ if (fileName === void 0) {
91751
+ agents.push(entry);
91752
+ continue;
91753
+ }
91754
+ const generated = generatedByFileName.get(fileName);
91755
+ if (generated !== void 0) {
91756
+ if (!placed.has(fileName)) {
91757
+ agents.push(generated);
91758
+ placed.add(fileName);
91759
+ }
91760
+ } else if (!this.deleteOrphans && this.existingAgentFileNames.has(fileName)) agents.push(entry);
91761
+ else dropped.push(String(entry));
91762
+ }
91763
+ for (const [fileName, entry] of generatedByFileName) if (!placed.has(fileName)) {
91764
+ agents.push(entry);
91765
+ placed.add(fileName);
91766
+ }
91767
+ if (dropped.length > 0) this.logger?.warn(`Unregistered Firebender agent(s) ${dropped.map((entry) => quoteValueForWarning(entry)).join(", ")} from ${filePath}: ` + (this.deleteOrphans ? "--delete removes agent files that .rulesync/subagents/ no longer generates." : "the agent file no longer exists."));
91768
+ return applySharedConfigPatch({
91769
+ fileKey: sharedConfigFileKey(paths),
91770
+ feature: "subagents",
91771
+ existingContent,
91772
+ patch: { agents: agents.length > 0 ? agents : void 0 },
91773
+ filePath,
91774
+ logger: this.logger
91775
+ });
91776
+ }
91777
+ toRulesyncSubagent() {
91778
+ throw new Error(`${this.getRelativePathFromCwd()} registers Firebender agents and is not a subagent itself.`);
91779
+ }
91780
+ validate() {
91781
+ return {
91782
+ success: true,
91783
+ error: null
91784
+ };
91785
+ }
91786
+ };
91787
+ //#endregion
89481
91788
  //#region src/features/subagents/goose-subagent.ts
89482
91789
  /**
89483
91790
  * Goose custom agents (v1.34.0+) are Markdown files with YAML frontmatter —
@@ -89765,13 +92072,13 @@ function subagentSlug(relativeFilePath) {
89765
92072
  return (0, node_path.basename)(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_");
89766
92073
  }
89767
92074
  function hermesCommandName(slug) {
89768
- return `rulesync_subagent_${slug}`;
92075
+ return hermesSlashName(`rulesync-subagent-${slug}`);
89769
92076
  }
89770
92077
  function getPluginManifestContent() {
89771
92078
  return [
89772
92079
  "name: rulesync-subagents",
89773
92080
  "version: \"1.0.0\"",
89774
- "description: Exposes RuleSync subagents as Hermes native delegation commands.",
92081
+ "description: Exposes RuleSync subagents as Hermes slash commands that queue a delegation prompt.",
89775
92082
  ""
89776
92083
  ].join("\n");
89777
92084
  }
@@ -89779,9 +92086,12 @@ function getPluginInitContent() {
89779
92086
  return `"""RuleSync-generated Hermes subagent commands."""
89780
92087
 
89781
92088
  import json
92089
+ import logging
92090
+ import re
89782
92091
  from pathlib import Path
89783
92092
 
89784
92093
 
92094
+ logger = logging.getLogger(__name__)
89785
92095
  SUBAGENTS_DIR = Path(__file__).resolve().parents[2] / "rulesync" / "subagents"
89786
92096
 
89787
92097
 
@@ -89801,52 +92111,71 @@ def _load_subagents():
89801
92111
  return subagents
89802
92112
 
89803
92113
 
89804
- def _register_subagent(ctx, subagent):
92114
+ def _command_name(subagent, slug):
92115
+ # The spec carries the name Rulesync normalized; Hermes' messaging gateway
92116
+ # resolves plugin commands with "_" turned into "-", so keep it hyphenated.
92117
+ hermes = subagent.get("hermes")
92118
+ name = hermes.get("command") if isinstance(hermes, dict) else None
92119
+ name = name or f"rulesync-subagent-{slug}"
92120
+ # Mirrors hermesSlashName() in src/features/shared/hermes-plugin-commands.ts.
92121
+ name = re.sub(r"[ _]", "-", str(name).lower())
92122
+ name = re.sub(r"[^a-z0-9-]", "", name)
92123
+ return re.sub(r"-{2,}", "-", name).strip("-")
92124
+
92125
+
92126
+ ${getHermesQueueUserMessagePython({ pluginName: "rulesync-subagents" })}
92127
+
92128
+ def _register_subagent(ctx, subagent, registered):
89805
92129
  slug = subagent.get("slug")
89806
92130
  if not slug:
89807
92131
  return
89808
92132
 
89809
- command_name = f"rulesync_subagent_{slug}"
92133
+ command_name = _command_name(subagent, slug)
92134
+ if command_name in registered:
92135
+ logger.warning(
92136
+ "rulesync-subagents: %s and %s both map to /%s; keeping the first.",
92137
+ registered[command_name],
92138
+ subagent.get("_path"),
92139
+ command_name,
92140
+ )
92141
+ return
92142
+ registered[command_name] = subagent.get("_path")
92143
+
89810
92144
  name = subagent.get("name") or slug
89811
92145
  description = subagent.get("description") or f"Delegate work to the {name} RuleSync subagent."
89812
92146
  system_prompt = subagent.get("prompt") or ""
89813
92147
 
89814
92148
  def handler(args=None, **kwargs):
89815
- del kwargs
89816
92149
  user_context = ""
89817
92150
  if isinstance(args, dict):
89818
92151
  user_context = args.get("context") or args.get("task") or args.get("prompt") or ""
89819
92152
  elif args is not None:
89820
92153
  user_context = str(args)
89821
92154
 
89822
- context_parts = []
92155
+ # A slash command runs outside any agent turn, so calling delegate_task
92156
+ # here fails with "delegate_task requires a parent agent context".
92157
+ # Queue the brief as a user message instead; the agent then delegates.
92158
+ parts = [
92159
+ f"Use the delegate_task tool to hand the following task to a subagent "
92160
+ f"acting as the {name} RuleSync subagent.",
92161
+ f"Goal: {description}",
92162
+ ]
89823
92163
  if system_prompt:
89824
- context_parts.append(system_prompt)
92164
+ parts.append(f"Subagent instructions:\\n{system_prompt}")
89825
92165
  if user_context:
89826
- context_parts.append(user_context)
92166
+ parts.append(f"Task details:\\n{user_context}")
89827
92167
 
89828
- # delegate_task takes no model-facing "toolsets" argument: a subagent
89829
- # inherits the parent's enabled toolsets (role="orchestrator" is the
89830
- # only knob that changes them). "tasks" is the only shape its schema
89831
- # advertises; a top-level "goal" is an unadvertised legacy path.
89832
- return ctx.dispatch_tool(
89833
- "delegate_task",
89834
- {
89835
- "tasks": [
89836
- {
89837
- "goal": description,
89838
- "context": "\\n\\n".join(context_parts),
89839
- },
89840
- ],
89841
- },
89842
- )
92168
+ label = f"the {name} subagent task"
92169
+ error = _queue_user_message(ctx, "\\n\\n".join(parts), kwargs, label)
92170
+ return error or f"Queued {label}; the agent will delegate it."
89843
92171
 
89844
92172
  ctx.register_command(command_name, handler, description)
89845
92173
 
89846
92174
 
89847
92175
  def register(ctx):
92176
+ registered = {}
89848
92177
  for subagent in _load_subagents():
89849
- _register_subagent(ctx, subagent)
92178
+ _register_subagent(ctx, subagent, registered)
89850
92179
  `;
89851
92180
  }
89852
92181
  function getEnabledPluginConfigContent({ currentContent, global }) {
@@ -89877,7 +92206,7 @@ function getSubagentSpec(rulesyncSubagent) {
89877
92206
  prompt: rulesyncSubagent.getBody(),
89878
92207
  hermes: {
89879
92208
  command: hermesCommandName(slug),
89880
- dispatch: "delegate_task"
92209
+ dispatch: "inject_message"
89881
92210
  }
89882
92211
  };
89883
92212
  }
@@ -90553,6 +92882,15 @@ const KiroCliSubagentJsonSchema = zod_mini.z.looseObject({
90553
92882
  allowedTools: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.array(zod_mini.z.string()))),
90554
92883
  includeMcpJson: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.boolean()))
90555
92884
  });
92885
+ /**
92886
+ * The name Kiro gives an agent file: its path relative to the agents directory
92887
+ * without the extension, so a nested `team/planner.md` is `team/planner`.
92888
+ * https://kiro.dev/docs/custom-agents/
92889
+ */
92890
+ function kiroAgentNameFromPath(relativeFilePath) {
92891
+ const posixPath = toPosixPath(relativeFilePath);
92892
+ return posixPath.slice(0, posixPath.length - (0, node_path.extname)(posixPath).length);
92893
+ }
90556
92894
  var KiroSubagent = class extends ToolSubagent {
90557
92895
  body;
90558
92896
  constructor({ body, ...rest }) {
@@ -90588,7 +92926,7 @@ var KiroSubagent = class extends ToolSubagent {
90588
92926
  outputRoot: ".",
90589
92927
  frontmatter: {
90590
92928
  targets: [toolTarget],
90591
- name: name ?? (0, node_path.basename)(this.getRelativeFilePath(), ".json"),
92929
+ name: name ?? kiroAgentNameFromPath(this.getRelativeFilePath()),
90592
92930
  description: description ?? void 0,
90593
92931
  ...Object.keys(kiroSection).length > 0 && { kiro: kiroSection }
90594
92932
  },
@@ -90738,7 +93076,7 @@ var KiroIdeSubagent = class KiroIdeSubagent extends ToolSubagent {
90738
93076
  outputRoot: ".",
90739
93077
  frontmatter: {
90740
93078
  targets: ["*"],
90741
- name: name ?? this.getRelativeFilePath().replace(/\.md$/, ""),
93079
+ name: name ?? kiroAgentNameFromPath(this.getRelativeFilePath()),
90742
93080
  description: description ?? "",
90743
93081
  ...Object.keys(kiroIdeSection).length > 0 && { "kiro-ide": kiroIdeSection }
90744
93082
  },
@@ -92669,6 +95007,166 @@ var VibeSubagent = class VibeSubagent extends ToolSubagent {
92669
95007
  }
92670
95008
  };
92671
95009
  //#endregion
95010
+ //#region src/features/subagents/xum-subagent.ts
95011
+ /**
95012
+ * The built-in agent a rulesync subagent inherits from when its `xum` block
95013
+ * names neither a `base` nor its own `tools`. Xum gives an agent no tools at
95014
+ * all unless some agent in its `base` chain declares them, so a definition
95015
+ * without either would be a prompt that cannot read or edit anything.
95016
+ */
95017
+ const XUM_DEFAULT_BASE_AGENT = "exec";
95018
+ /**
95019
+ * Frontmatter of a Xum agent definition. `name` (the display name) is
95020
+ * required; the other documented fields are typed loosely and unknown keys are
95021
+ * kept (looseObject) so a round trip never drops them.
95022
+ *
95023
+ * @see https://xum.cdr.dev/agents
95024
+ */
95025
+ const XumSubagentFrontmatterSchema = zod_mini.z.looseObject({
95026
+ name: zod_mini.z.string(),
95027
+ description: zod_mini.z.optional(zod_mini.z.string()),
95028
+ base: zod_mini.z.optional(zod_mini.z.string()),
95029
+ disabled: zod_mini.z.optional(zod_mini.z.boolean()),
95030
+ ui: zod_mini.z.optional(zod_mini.z.looseObject({})),
95031
+ prompt: zod_mini.z.optional(zod_mini.z.looseObject({})),
95032
+ subagent: zod_mini.z.optional(zod_mini.z.looseObject({})),
95033
+ ai: zod_mini.z.optional(zod_mini.z.looseObject({})),
95034
+ tools: zod_mini.z.optional(zod_mini.z.looseObject({}))
95035
+ });
95036
+ /**
95037
+ * Subagent generator for Xum (formerly Mux).
95038
+ *
95039
+ * Xum reads custom agent definitions — Markdown with YAML frontmatter — from
95040
+ * the top level of `.xum/agents/` (project) and `~/.xum/agents/` (user); the
95041
+ * body is the agent's instruction prompt. The file name (without `.md`) is the
95042
+ * agent id, so it must be lowercase letters and digits with single `-` / `_`
95043
+ * separators; Xum skips other files with a warning.
95044
+ *
95045
+ * rulesync writes `name` and `description`, spreads the `xum` section (`base`,
95046
+ * `ui`, `prompt`, `subagent`, `ai`, `tools`, ...) on top, and fills two
95047
+ * defaults so the definition works as a delegated subagent out of the box:
95048
+ * `subagent.runnable: true` (required for `task({ agentId })` to spawn it) and,
95049
+ * when the section sets neither `base` nor `tools`, `base: exec`.
95050
+ *
95051
+ * @see https://xum.cdr.dev/agents
95052
+ */
95053
+ var XumSubagent = class XumSubagent extends ToolSubagent {
95054
+ frontmatter;
95055
+ body;
95056
+ constructor({ frontmatter, body, ...rest }) {
95057
+ if (rest.validate !== false) {
95058
+ const result = XumSubagentFrontmatterSchema.safeParse(frontmatter);
95059
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
95060
+ }
95061
+ super({ ...rest });
95062
+ this.frontmatter = frontmatter;
95063
+ this.body = body;
95064
+ }
95065
+ static getSettablePaths(_options = {}) {
95066
+ return { relativeDirPath: XUM_AGENTS_DIR_PATH };
95067
+ }
95068
+ getFrontmatter() {
95069
+ return this.frontmatter;
95070
+ }
95071
+ getBody() {
95072
+ return this.body;
95073
+ }
95074
+ toRulesyncSubagent() {
95075
+ const { name, description, ...restFields } = this.frontmatter;
95076
+ return new RulesyncSubagent({
95077
+ outputRoot: ".",
95078
+ frontmatter: {
95079
+ targets: ["*"],
95080
+ name,
95081
+ description,
95082
+ ...Object.keys(restFields).length > 0 && { xum: restFields }
95083
+ },
95084
+ body: this.body,
95085
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
95086
+ relativeFilePath: this.getRelativeFilePath(),
95087
+ validate: true
95088
+ });
95089
+ }
95090
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
95091
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
95092
+ const xumSection = this.filterToolSpecificSection(rulesyncFrontmatter.xum ?? {}, ["name", "description"]);
95093
+ const subagentSection = xumSection.subagent;
95094
+ const result = XumSubagentFrontmatterSchema.safeParse({
95095
+ name: rulesyncFrontmatter.name,
95096
+ ...rulesyncFrontmatter.description && { description: rulesyncFrontmatter.description },
95097
+ ...xumSection.base === void 0 && xumSection.tools === void 0 && { base: XUM_DEFAULT_BASE_AGENT },
95098
+ ...xumSection,
95099
+ subagent: {
95100
+ runnable: true,
95101
+ ...isPlainObject$1(subagentSection) ? subagentSection : {}
95102
+ }
95103
+ });
95104
+ if (!result.success) throw new Error(`Invalid xum subagent frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(result.error)}`);
95105
+ const body = rulesyncSubagent.getBody();
95106
+ return new XumSubagent({
95107
+ outputRoot,
95108
+ frontmatter: result.data,
95109
+ body,
95110
+ relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
95111
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
95112
+ fileContent: stringifyFrontmatter(body, result.data),
95113
+ validate
95114
+ });
95115
+ }
95116
+ validate() {
95117
+ if (!this.frontmatter) return {
95118
+ success: true,
95119
+ error: null
95120
+ };
95121
+ const result = XumSubagentFrontmatterSchema.safeParse(this.frontmatter);
95122
+ if (result.success) return {
95123
+ success: true,
95124
+ error: null
95125
+ };
95126
+ return {
95127
+ success: false,
95128
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
95129
+ };
95130
+ }
95131
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
95132
+ return this.isTargetedByRulesyncSubagentDefault({
95133
+ rulesyncSubagent,
95134
+ toolTarget: "xum"
95135
+ });
95136
+ }
95137
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
95138
+ const paths = this.getSettablePaths({ global });
95139
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
95140
+ const fileContent = await readFileContent(filePath);
95141
+ const { frontmatter, body } = parseFrontmatter(fileContent, filePath);
95142
+ const result = XumSubagentFrontmatterSchema.safeParse(frontmatter);
95143
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
95144
+ return new XumSubagent({
95145
+ outputRoot,
95146
+ relativeDirPath: paths.relativeDirPath,
95147
+ relativeFilePath,
95148
+ frontmatter: result.data,
95149
+ body: body.trim(),
95150
+ fileContent,
95151
+ validate
95152
+ });
95153
+ }
95154
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
95155
+ return new XumSubagent({
95156
+ outputRoot,
95157
+ relativeDirPath,
95158
+ relativeFilePath,
95159
+ frontmatter: {
95160
+ name: "",
95161
+ description: ""
95162
+ },
95163
+ body: "",
95164
+ fileContent: "",
95165
+ validate: false
95166
+ });
95167
+ }
95168
+ };
95169
+ //#endregion
92672
95170
  //#region src/features/subagents/zcode-subagent.ts
92673
95171
  const ZcodeSubagentFrontmatterSchema = zod_mini.z.looseObject({
92674
95172
  name: zod_mini.z.string(),
@@ -93134,6 +95632,17 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
93134
95632
  filePattern: "*.md"
93135
95633
  }
93136
95634
  }],
95635
+ ["firebender", {
95636
+ class: FirebenderSubagent,
95637
+ meta: {
95638
+ supportsProject: true,
95639
+ supportsSimulated: false,
95640
+ supportsGlobal: true,
95641
+ emitsEmptyAggregate: true,
95642
+ emitsEmptyAggregateOnDelete: true,
95643
+ filePattern: "*.md"
95644
+ }
95645
+ }],
93137
95646
  ["goose", {
93138
95647
  class: GooseSubagent,
93139
95648
  meta: {
@@ -93185,7 +95694,8 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
93185
95694
  supportsProject: true,
93186
95695
  supportsSimulated: false,
93187
95696
  supportsGlobal: true,
93188
- filePattern: "*.json"
95697
+ filePattern: "**/*.json",
95698
+ supportsNestedPaths: true
93189
95699
  }
93190
95700
  }],
93191
95701
  ["kiro-ide", {
@@ -93194,7 +95704,8 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
93194
95704
  supportsProject: true,
93195
95705
  supportsSimulated: false,
93196
95706
  supportsGlobal: true,
93197
- filePattern: "*.md"
95707
+ filePattern: "**/*.md",
95708
+ supportsNestedPaths: true
93198
95709
  }
93199
95710
  }],
93200
95711
  ["lettacode", {
@@ -93361,6 +95872,15 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
93361
95872
  filePattern: "*.toml"
93362
95873
  }
93363
95874
  }],
95875
+ ["xum", {
95876
+ class: XumSubagent,
95877
+ meta: {
95878
+ supportsProject: true,
95879
+ supportsSimulated: false,
95880
+ supportsGlobal: true,
95881
+ filePattern: "*.md"
95882
+ }
95883
+ }],
93364
95884
  ["zcode", {
93365
95885
  class: ZcodeSubagent,
93366
95886
  meta: {
@@ -93415,6 +95935,7 @@ function compareRelativePathsShallowestFirst(left, right) {
93415
95935
  var SubagentsProcessor = class extends FeatureProcessor {
93416
95936
  toolTarget;
93417
95937
  global;
95938
+ deleteOrphans;
93418
95939
  getFactory;
93419
95940
  /**
93420
95941
  * Whether the last `loadRulesyncFiles` found a `subagents/` directory under
@@ -93422,13 +95943,14 @@ var SubagentsProcessor = class extends FeatureProcessor {
93422
95943
  * agents in its Pool settings are its own and must not be retracted.
93423
95944
  */
93424
95945
  rulesyncSourceDirFound = false;
93425
- constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
95946
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, deleteOrphans = false, logger }) {
93426
95947
  super({
93427
95948
  outputRoot,
93428
95949
  inputRoots,
93429
95950
  dryRun,
93430
95951
  logger
93431
95952
  });
95953
+ this.deleteOrphans = deleteOrphans;
93432
95954
  const result = SubagentsProcessorToolTargetSchema.safeParse(toolTarget);
93433
95955
  if (!result.success) throw new Error(`Invalid tool target for SubagentsProcessor: ${toolTarget}. ${formatError(result.error)}`);
93434
95956
  this.toolTarget = result.data;
@@ -93436,7 +95958,9 @@ var SubagentsProcessor = class extends FeatureProcessor {
93436
95958
  this.getFactory = getFactory;
93437
95959
  }
93438
95960
  emitsToolFilesForEmptySource() {
93439
- return this.rulesyncSourceDirFound && this.getFactory(this.toolTarget).meta.emitsEmptyAggregate === true;
95961
+ const { meta } = this.getFactory(this.toolTarget);
95962
+ if (meta.emitsEmptyAggregate !== true) return false;
95963
+ return this.rulesyncSourceDirFound || this.deleteOrphans && meta.emitsEmptyAggregateOnDelete === true;
93440
95964
  }
93441
95965
  async convertRulesyncFilesToToolFiles(rulesyncFiles) {
93442
95966
  const rulesyncSubagents = rulesyncFiles.filter((file) => file instanceof RulesyncSubagent);
@@ -93452,11 +95976,12 @@ var SubagentsProcessor = class extends FeatureProcessor {
93452
95976
  });
93453
95977
  if (factory.class.fromRulesyncSubagents) {
93454
95978
  if (targeted.length === 0 && !this.emitsToolFilesForEmptySource()) return [];
93455
- const toolSubagents = factory.class.fromRulesyncSubagents({
95979
+ const toolSubagents = await factory.class.fromRulesyncSubagents({
93456
95980
  outputRoot: this.outputRoot,
93457
95981
  rulesyncSubagents: targeted,
93458
95982
  global: this.global,
93459
- logger: this.logger
95983
+ logger: this.logger,
95984
+ deleteOrphans: this.deleteOrphans && !this.hasRulesyncSourceLoadFailure()
93460
95985
  });
93461
95986
  return Array.isArray(toolSubagents) ? toolSubagents : [toolSubagents];
93462
95987
  }
@@ -94202,7 +96727,7 @@ const isAiassistantApply = (value) => AIASSISTANT_APPLY_VALUES.includes(value);
94202
96727
  /**
94203
96728
  * Globs that match the whole project and so say nothing beyond "always".
94204
96729
  */
94205
- const UNIVERSAL_GLOBS$5 = /* @__PURE__ */ new Set(["**/*", "*"]);
96730
+ const UNIVERSAL_GLOBS$6 = /* @__PURE__ */ new Set(["**/*", "*"]);
94206
96731
  const METADATA_DELIMITER = "---";
94207
96732
  /**
94208
96733
  * Splits text into lines that keep their own line terminator, so the body can
@@ -94327,7 +96852,7 @@ var AiassistantRule = class AiassistantRule extends ToolRule {
94327
96852
  */
94328
96853
  static buildMetadata(frontmatter) {
94329
96854
  const globs = (frontmatter.globs ?? []).map((glob) => flattenValue(glob)).filter((glob) => glob.length > 0).flatMap((glob) => expandBraceAlternations(glob));
94330
- const hasUniversalGlob = globs.some((glob) => UNIVERSAL_GLOBS$5.has(glob));
96855
+ const hasUniversalGlob = globs.some((glob) => UNIVERSAL_GLOBS$6.has(glob));
94331
96856
  const instructions = frontmatter.description && flattenValue(frontmatter.description);
94332
96857
  const explicitApply = frontmatter.aiassistant?.apply && flattenValue(frontmatter.aiassistant.apply);
94333
96858
  const apply = explicitApply ? explicitApply : frontmatter.root !== true && globs.length > 0 && !hasUniversalGlob ? "by file patterns" : frontmatter.root !== true && globs.length === 0 && instructions ? "by model decision" : "always";
@@ -96981,7 +99506,7 @@ function normalizePatternList(value) {
96981
99506
  * an always-on rule into a "when any file is in context" rule. Dropped on
96982
99507
  * generate and mapped back on import.
96983
99508
  */
96984
- const UNIVERSAL_GLOBS$4 = /* @__PURE__ */ new Set(["**/*", "*"]);
99509
+ const UNIVERSAL_GLOBS$5 = /* @__PURE__ */ new Set(["**/*", "*"]);
96985
99510
  /**
96986
99511
  * Rule generator for Continue (continuedev/continue), the open-source IDE
96987
99512
  * extension and CLI (`cn`).
@@ -97095,7 +99620,7 @@ var ContinueRule = class ContinueRule extends ToolRule {
97095
99620
  if (alwaysApply === true) return;
97096
99621
  const targetGlobs = continueGlobs ?? parentGlobs;
97097
99622
  if (!targetGlobs || targetGlobs.length === 0) return;
97098
- if (targetGlobs.every((glob) => UNIVERSAL_GLOBS$4.has(glob.trim()))) return;
99623
+ if (targetGlobs.every((glob) => UNIVERSAL_GLOBS$5.has(glob.trim()))) return;
97099
99624
  return targetGlobs;
97100
99625
  }
97101
99626
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
@@ -97587,7 +100112,7 @@ const CursorRuleFrontmatterSchema = zod_mini.z.object({
97587
100112
  * does not already say. Same pair the Cline and Qwen Code adapters treat as
97588
100113
  * universal.
97589
100114
  */
97590
- const UNIVERSAL_GLOBS$3 = /* @__PURE__ */ new Set(["**/*", "*"]);
100115
+ const UNIVERSAL_GLOBS$4 = /* @__PURE__ */ new Set(["**/*", "*"]);
97591
100116
  var CursorRule = class CursorRule extends ToolRule {
97592
100117
  frontmatter;
97593
100118
  body;
@@ -97628,7 +100153,8 @@ var CursorRule = class CursorRule extends ToolRule {
97628
100153
  }
97629
100154
  /**
97630
100155
  * Custom parse function for Cursor MDC files
97631
- * MDC files don't support quotes in YAML, so we need to handle patterns like *.ts specially
100156
+ * MDC files don't support quotes in YAML, so we need to handle patterns like *.ts specially.
100157
+ * Also used by Firebender, whose `.firebender/rules/*.mdc` files carry the same frontmatter.
97632
100158
  */
97633
100159
  static parseCursorFrontmatter(fileContent, filePath) {
97634
100160
  const bounds = findFrontmatterBlockBounds(fileContent);
@@ -97691,7 +100217,7 @@ var CursorRule = class CursorRule extends ToolRule {
97691
100217
  static resolveCursorGlobs({ cursorSpecificGlobs, parentGlobs, alwaysApply }) {
97692
100218
  const targetGlobs = cursorSpecificGlobs !== void 0 ? cursorSpecificGlobs : parentGlobs;
97693
100219
  if (!targetGlobs || targetGlobs.length === 0) return;
97694
- if (alwaysApply && targetGlobs.every((glob) => UNIVERSAL_GLOBS$3.has(glob.trim()))) return;
100220
+ if (alwaysApply && targetGlobs.every((glob) => UNIVERSAL_GLOBS$4.has(glob.trim()))) return;
97695
100221
  return targetGlobs.join(",");
97696
100222
  }
97697
100223
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true }) {
@@ -98605,6 +101131,211 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
98605
101131
  }
98606
101132
  };
98607
101133
  //#endregion
101134
+ //#region src/features/rules/firebender-rule.ts
101135
+ /**
101136
+ * Frontmatter of a Firebender `.mdc` rule. Every field is optional; `globs`
101137
+ * is one comma-separated string, and a YAML list is tolerated on import.
101138
+ */
101139
+ const FirebenderRuleFrontmatterSchema = zod_mini.z.looseObject({
101140
+ description: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.string())),
101141
+ globs: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))),
101142
+ alwaysApply: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.boolean()))
101143
+ });
101144
+ /** Globs that match every file, and so add nothing to an always-applied rule. */
101145
+ const UNIVERSAL_GLOBS$3 = /* @__PURE__ */ new Set(["**/*", "*"]);
101146
+ function normalizeGlobs$2(globs) {
101147
+ if (globs === null || globs === void 0) return [];
101148
+ return (typeof globs === "string" ? globs.split(",") : globs).map((glob) => glob.trim()).filter((glob) => glob.length > 0);
101149
+ }
101150
+ /**
101151
+ * Rule generator for Firebender.
101152
+ *
101153
+ * Root rule: Firebender looks for `.firebender/AGENTS.md` first (then the
101154
+ * project-root `AGENTS.md`, then the same pair in parent directories, then
101155
+ * `~/.firebender/AGENTS.md`) and uses the first file it finds, so the root rule
101156
+ * is written to `.firebender/AGENTS.md` in the project and to
101157
+ * `~/.firebender/AGENTS.md` globally, where it does not collide with the
101158
+ * project-root `AGENTS.md` the `agentsmd` target owns.
101159
+ *
101160
+ * Non-root rules: `.firebender/rules/*.mdc` (project) and
101161
+ * `~/.firebender/rules/*.mdc` (personal), each with optional Cursor-style
101162
+ * frontmatter: `description`, `globs` (one comma-separated string) and
101163
+ * `alwaysApply`. A rule with specific globs is written with
101164
+ * `alwaysApply: false` and those globs; every other rule is always applied.
101165
+ *
101166
+ * @see https://docs.firebender.com/api-reference/rules
101167
+ * @see https://docs.firebender.com/api-reference/agents-md
101168
+ */
101169
+ var FirebenderRule = class FirebenderRule extends ToolRule {
101170
+ frontmatter;
101171
+ body;
101172
+ constructor({ frontmatter = {}, body, ...rest }) {
101173
+ if (rest.validate && !rest.root) {
101174
+ const result = FirebenderRuleFrontmatterSchema.safeParse(frontmatter);
101175
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
101176
+ }
101177
+ const resolvedBody = body ?? rest.fileContent;
101178
+ super({
101179
+ ...rest,
101180
+ fileContent: rest.root ? resolvedBody : stringifyFrontmatter(resolvedBody, FirebenderRule.toOutputFrontmatter(frontmatter), { avoidBlockScalars: true })
101181
+ });
101182
+ this.frontmatter = frontmatter;
101183
+ this.body = resolvedBody;
101184
+ }
101185
+ static getSettablePaths({ excludeToolDir } = {}) {
101186
+ return {
101187
+ root: {
101188
+ relativeDirPath: buildToolPath(FIREBENDER_DIR, ".", excludeToolDir),
101189
+ relativeFilePath: FIREBENDER_AGENTS_MD_FILE_NAME
101190
+ },
101191
+ nonRoot: { relativeDirPath: buildToolPath(FIREBENDER_DIR, "rules", excludeToolDir) }
101192
+ };
101193
+ }
101194
+ /**
101195
+ * The frontmatter written to a `.mdc` file: `alwaysApply` first, then the
101196
+ * flattened `description`, then the globs joined the way Firebender's docs
101197
+ * spell them (`"*.kt, *.kts"`).
101198
+ */
101199
+ static toOutputFrontmatter(frontmatter) {
101200
+ const description = typeof frontmatter.description === "string" ? frontmatter.description.replace(/[\r\n\u0085\u2028\u2029]+/g, " ").trim() : "";
101201
+ const globs = normalizeGlobs$2(frontmatter.globs);
101202
+ return {
101203
+ ...typeof frontmatter.alwaysApply === "boolean" && { alwaysApply: frontmatter.alwaysApply },
101204
+ ...description !== "" && { description },
101205
+ ...globs.length > 0 && { globs: globs.join(", ") }
101206
+ };
101207
+ }
101208
+ /**
101209
+ * Derives the Firebender frontmatter from the canonical one: specific globs
101210
+ * make a file-scoped rule, everything else (no globs, universal globs) is
101211
+ * always applied. Firebender splits `globs` on every comma, so a brace
101212
+ * alternation such as `*.{ts,tsx}` is expanded into one glob per branch.
101213
+ */
101214
+ static buildFrontmatter(frontmatter) {
101215
+ const globs = normalizeGlobs$2(frontmatter.globs ?? []).flatMap((glob) => expandBraceAlternations(glob));
101216
+ const specificGlobs = globs.filter((glob) => !UNIVERSAL_GLOBS$3.has(glob));
101217
+ const isFileScoped = specificGlobs.length > 0 && specificGlobs.length === globs.length;
101218
+ const description = frontmatter.description?.trim() || void 0;
101219
+ return {
101220
+ alwaysApply: !isFileScoped,
101221
+ ...description !== void 0 && { description },
101222
+ ...isFileScoped && { globs: specificGlobs.join(", ") }
101223
+ };
101224
+ }
101225
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true }) {
101226
+ const paths = this.getSettablePaths();
101227
+ const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
101228
+ if (rulesyncFrontmatter.root ?? false) return new FirebenderRule({
101229
+ outputRoot,
101230
+ relativeDirPath: paths.root.relativeDirPath,
101231
+ relativeFilePath: paths.root.relativeFilePath,
101232
+ fileContent: rulesyncRule.getBody(),
101233
+ validate,
101234
+ root: true,
101235
+ description: rulesyncFrontmatter.description,
101236
+ globs: rulesyncFrontmatter.globs
101237
+ });
101238
+ return new FirebenderRule({
101239
+ outputRoot,
101240
+ relativeDirPath: paths.nonRoot.relativeDirPath,
101241
+ relativeFilePath: rulesyncRule.getRelativeFilePath().replace(/\.md$/, ".mdc"),
101242
+ frontmatter: this.buildFrontmatter(rulesyncFrontmatter),
101243
+ body: rulesyncRule.getBody(),
101244
+ fileContent: "",
101245
+ validate,
101246
+ root: false
101247
+ });
101248
+ }
101249
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
101250
+ const paths = this.getSettablePaths();
101251
+ if (relativeFilePath === paths.root.relativeFilePath && (relativeDirPath === void 0 || relativeDirPath === paths.root.relativeDirPath)) {
101252
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.root.relativeDirPath, paths.root.relativeFilePath));
101253
+ return new FirebenderRule({
101254
+ outputRoot,
101255
+ relativeDirPath: paths.root.relativeDirPath,
101256
+ relativeFilePath: paths.root.relativeFilePath,
101257
+ fileContent,
101258
+ validate,
101259
+ root: true
101260
+ });
101261
+ }
101262
+ const filePath = (0, node_path.join)(outputRoot, paths.nonRoot.relativeDirPath, relativeFilePath);
101263
+ const fileContent = await readFileContent(filePath);
101264
+ const { frontmatter, body } = CursorRule.parseCursorFrontmatter(fileContent, filePath);
101265
+ const result = FirebenderRuleFrontmatterSchema.safeParse(frontmatter);
101266
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
101267
+ return new FirebenderRule({
101268
+ outputRoot,
101269
+ relativeDirPath: paths.nonRoot.relativeDirPath,
101270
+ relativeFilePath,
101271
+ frontmatter: result.data,
101272
+ body: body.trim(),
101273
+ fileContent: "",
101274
+ validate,
101275
+ root: false
101276
+ });
101277
+ }
101278
+ toRulesyncRule() {
101279
+ if (this.isRoot()) return this.toRulesyncRuleDefault();
101280
+ const sourceGlobs = normalizeGlobs$2(this.frontmatter.globs);
101281
+ const alwaysApply = this.frontmatter.alwaysApply ?? void 0;
101282
+ const description = this.frontmatter.description?.trim() || void 0;
101283
+ const globs = alwaysApply === true || sourceGlobs.length === 0 ? ["**/*"] : sourceGlobs;
101284
+ return new RulesyncRule({
101285
+ outputRoot: process.cwd(),
101286
+ frontmatter: {
101287
+ targets: ["*"],
101288
+ root: false,
101289
+ ...description !== void 0 && { description },
101290
+ globs
101291
+ },
101292
+ body: this.body,
101293
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
101294
+ relativeFilePath: this.getRelativeFilePath().replace(/\.mdc$/, ".md"),
101295
+ validate: true
101296
+ });
101297
+ }
101298
+ validate() {
101299
+ if (this.isRoot() || !this.frontmatter) return {
101300
+ success: true,
101301
+ error: null
101302
+ };
101303
+ const result = FirebenderRuleFrontmatterSchema.safeParse(this.frontmatter);
101304
+ if (result.success) return {
101305
+ success: true,
101306
+ error: null
101307
+ };
101308
+ return {
101309
+ success: false,
101310
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
101311
+ };
101312
+ }
101313
+ getFrontmatter() {
101314
+ return this.frontmatter;
101315
+ }
101316
+ getBody() {
101317
+ return this.body;
101318
+ }
101319
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
101320
+ const paths = this.getSettablePaths();
101321
+ const isRoot = relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
101322
+ return new FirebenderRule({
101323
+ outputRoot,
101324
+ relativeDirPath,
101325
+ relativeFilePath,
101326
+ fileContent: "",
101327
+ validate: false,
101328
+ root: isRoot
101329
+ });
101330
+ }
101331
+ static isTargetedByRulesyncRule(rulesyncRule) {
101332
+ return this.isTargetedByRulesyncRuleDefault({
101333
+ rulesyncRule,
101334
+ toolTarget: "firebender"
101335
+ });
101336
+ }
101337
+ };
101338
+ //#endregion
98608
101339
  //#region src/features/rules/gitlabduo-rule.ts
98609
101340
  /**
98610
101341
  * GitLab Duo CLI reads custom rules from a single plain-Markdown file:
@@ -101886,6 +104617,117 @@ var WarpRule = class extends NestedAgentsmdRule {
101886
104617
  }
101887
104618
  };
101888
104619
  //#endregion
104620
+ //#region src/features/rules/xum-rule.ts
104621
+ /** An ATX level-1 heading line (`# Title`), which Xum's parser treats as an H1. */
104622
+ const ATX_H1_LINE = /^ {0,3}#(?:[ \t]|$)/;
104623
+ /**
104624
+ * Make `body` start at a level-1 heading, adding `# <heading>` when it does not
104625
+ * already open with one.
104626
+ *
104627
+ * Xum reads `.xum/AGENTS.md` (and appends `AGENTS.local.md`) as one Markdown
104628
+ * document and treats a `Mode:` / `Model:` / `Tool:` heading as a scoped
104629
+ * section that runs until the next heading of the same or a higher level.
104630
+ * Fragments rulesync concatenates into that document — folded topic rules and
104631
+ * the local overlay — would otherwise fall inside a scoped section the
104632
+ * preceding fragment ends with, and Xum would inject them only in that mode.
104633
+ * Only an H1 closes a section of any level, so each such fragment opens with
104634
+ * one.
104635
+ *
104636
+ * @see https://github.com/coder/xum/blob/main/src/node/utils/main/markdown.ts
104637
+ */
104638
+ function ensureXumSectionBoundary({ body, heading }) {
104639
+ const firstLine = body.split(/\r?\n/).find((line) => line.trim() !== "");
104640
+ if (firstLine !== void 0 && ATX_H1_LINE.test(firstLine)) return body;
104641
+ const safeHeading = heading.replaceAll(/[\r\n:]/g, " ").trim() || "Instructions";
104642
+ return body.trim() === "" ? `# ${safeHeading}\n` : `# ${safeHeading}\n\n${body}`;
104643
+ }
104644
+ /** The heading a folded rule opens with: its file name without the extension. */
104645
+ function xumRuleHeading(rulesyncRule) {
104646
+ const fileName = (0, node_path.basename)(rulesyncRule.getRelativeFilePath());
104647
+ return fileName.slice(0, fileName.length - (0, node_path.extname)(fileName).length);
104648
+ }
104649
+ /**
104650
+ * Rule generator for Xum (Coder's parallel coding-agent app, formerly Mux).
104651
+ *
104652
+ * Xum layers its own `.xum/AGENTS.md` (project) and `~/.xum/AGENTS.md` (user)
104653
+ * on top of the shared root `AGENTS.md` / `AGENT.md` / `CLAUDE.md`, which the
104654
+ * `agentsmd` target already writes, so this target writes only the
104655
+ * Xum-dedicated file. Only these files honor the `## Model: <regex>` and
104656
+ * `## Mode: <mode>` scoped sections; rulesync passes the body through as-is,
104657
+ * so such sections authored in a rule keep working. The personal
104658
+ * `.xum/AGENTS.local.md` overlay is written for a `localRoot: true` rule.
104659
+ *
104660
+ * @see https://xum.cdr.dev/agents/instruction-files
104661
+ */
104662
+ var XumRule = class XumRule extends ToolRule {
104663
+ constructor({ fileContent, root, ...rest }) {
104664
+ super({
104665
+ ...rest,
104666
+ fileContent,
104667
+ root: root ?? false
104668
+ });
104669
+ }
104670
+ static getSettablePaths(_options = {}) {
104671
+ return { root: {
104672
+ relativeDirPath: XUM_DIR,
104673
+ relativeFilePath: XUM_RULE_FILE_NAME
104674
+ } };
104675
+ }
104676
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
104677
+ const { root } = this.getSettablePaths({ global });
104678
+ const relativePath = (0, node_path.join)(root.relativeDirPath, root.relativeFilePath);
104679
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
104680
+ return new XumRule({
104681
+ outputRoot,
104682
+ relativeDirPath: root.relativeDirPath,
104683
+ relativeFilePath: root.relativeFilePath,
104684
+ fileContent,
104685
+ validate,
104686
+ root: true
104687
+ });
104688
+ }
104689
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
104690
+ const { root } = this.getSettablePaths({ global });
104691
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
104692
+ return new XumRule({
104693
+ outputRoot,
104694
+ relativeDirPath: root.relativeDirPath,
104695
+ relativeFilePath: root.relativeFilePath,
104696
+ fileContent: isRoot ? rulesyncRule.getBody() : ensureXumSectionBoundary({
104697
+ body: rulesyncRule.getBody(),
104698
+ heading: xumRuleHeading(rulesyncRule)
104699
+ }),
104700
+ validate,
104701
+ root: isRoot
104702
+ });
104703
+ }
104704
+ toRulesyncRule() {
104705
+ return this.toRulesyncRuleDefault();
104706
+ }
104707
+ validate() {
104708
+ return {
104709
+ success: true,
104710
+ error: null
104711
+ };
104712
+ }
104713
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
104714
+ return new XumRule({
104715
+ outputRoot,
104716
+ relativeDirPath,
104717
+ relativeFilePath,
104718
+ fileContent: "",
104719
+ validate: false,
104720
+ root: relativeFilePath === "AGENTS.md" && relativeDirPath === ".xum"
104721
+ });
104722
+ }
104723
+ static isTargetedByRulesyncRule(rulesyncRule) {
104724
+ return this.isTargetedByRulesyncRuleDefault({
104725
+ rulesyncRule,
104726
+ toolTarget: "xum"
104727
+ });
104728
+ }
104729
+ };
104730
+ //#endregion
101889
104731
  //#region src/features/rules/zcode-rule.ts
101890
104732
  var ZcodeRule = class ZcodeRule extends ToolRule {
101891
104733
  constructor({ fileContent, root, ...rest }) {
@@ -102374,6 +105216,14 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
102374
105216
  collisionPolicy: "compose"
102375
105217
  }
102376
105218
  }],
105219
+ ["firebender", {
105220
+ class: FirebenderRule,
105221
+ meta: {
105222
+ extension: "mdc",
105223
+ supportsGlobal: true,
105224
+ ruleDiscoveryMode: "auto"
105225
+ }
105226
+ }],
102377
105227
  ["goose", {
102378
105228
  class: GooseRule,
102379
105229
  meta: {
@@ -102657,6 +105507,17 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
102657
105507
  collisionPolicy: "fold"
102658
105508
  }
102659
105509
  }],
105510
+ ["xum", {
105511
+ class: XumRule,
105512
+ meta: {
105513
+ extension: "md",
105514
+ supportsGlobal: true,
105515
+ ruleDiscoveryMode: "auto",
105516
+ collisionPolicy: "fold",
105517
+ localRootMode: "separate-local-file",
105518
+ localRootFileName: XUM_LOCAL_RULE_FILE_NAME
105519
+ }
105520
+ }],
102660
105521
  ["devin", {
102661
105522
  class: DevinRule,
102662
105523
  meta: {
@@ -103427,18 +106288,39 @@ var RulesProcessor = class extends FeatureProcessor {
103427
106288
  root: true,
103428
106289
  localRoot
103429
106290
  });
103430
- if (isClassOrSubclassOf({
103431
- candidate: factory.class,
103432
- base: QwencodeRule
103433
- })) return new QwencodeRule({
103434
- outputRoot: this.outputRoot,
103435
- relativeDirPath: relativeDirPath ?? ".qwen",
103436
- relativeFilePath: fileName,
103437
- fileContent: body,
103438
- validate: true,
103439
- root: true,
106291
+ return this.buildToolDirLocalRootFile({
106292
+ factory,
106293
+ fileName,
106294
+ body,
106295
+ relativeDirPath,
103440
106296
  localRoot
103441
106297
  });
106298
+ }
106299
+ /**
106300
+ * Qwen Code and Xum read the personal local file from their tool directory
106301
+ * (`.qwen/QWEN.local.md`, `.xum/AGENTS.local.md`), not the project root
106302
+ * (project scope only; global handling never reaches here).
106303
+ */
106304
+ buildToolDirLocalRootFile({ factory, fileName, body, relativeDirPath, localRoot }) {
106305
+ const toolDirEntry = [[QwencodeRule, QWENCODE_DIR], [XumRule, XUM_DIR]].find(([base]) => isClassOrSubclassOf({
106306
+ candidate: factory.class,
106307
+ base
106308
+ }));
106309
+ if (toolDirEntry) {
106310
+ const [toolDirClass, toolDir] = toolDirEntry;
106311
+ return new toolDirClass({
106312
+ outputRoot: this.outputRoot,
106313
+ relativeDirPath: relativeDirPath ?? toolDir,
106314
+ relativeFilePath: fileName,
106315
+ fileContent: toolDirClass === XumRule && !localRoot ? ensureXumSectionBoundary({
106316
+ body,
106317
+ heading: "Local instructions"
106318
+ }) : body,
106319
+ validate: true,
106320
+ root: true,
106321
+ localRoot
106322
+ });
106323
+ }
103442
106324
  return null;
103443
106325
  }
103444
106326
  /**
@@ -106190,6 +109072,7 @@ async function generateSubagentsCore(params) {
106190
109072
  toolTarget,
106191
109073
  global: config.getGlobal(),
106192
109074
  dryRun: config.isPreviewMode(),
109075
+ deleteOrphans: config.getDelete(),
106193
109076
  logger
106194
109077
  });
106195
109078
  const result = await processFeatureWithRulesyncFiles({
@@ -107064,6 +109947,12 @@ Object.defineProperty(exports, "PACKAGING_TOOL_TARGETS", {
107064
109947
  return PACKAGING_TOOL_TARGETS;
107065
109948
  }
107066
109949
  });
109950
+ Object.defineProperty(exports, "PathOutsideRootError", {
109951
+ enumerable: true,
109952
+ get: function() {
109953
+ return PathOutsideRootError;
109954
+ }
109955
+ });
107067
109956
  Object.defineProperty(exports, "QWENCODE_DIR", {
107068
109957
  enumerable: true,
107069
109958
  get: function() {
@@ -107364,6 +110253,12 @@ Object.defineProperty(exports, "assertDirectoryIfExists", {
107364
110253
  return assertDirectoryIfExists;
107365
110254
  }
107366
110255
  });
110256
+ Object.defineProperty(exports, "assertPathResolvesInsideRoot", {
110257
+ enumerable: true,
110258
+ get: function() {
110259
+ return assertPathResolvesInsideRoot;
110260
+ }
110261
+ });
107367
110262
  Object.defineProperty(exports, "assertTargetsFeaturesExclusive", {
107368
110263
  enumerable: true,
107369
110264
  get: function() {