rulesync 16.6.0 → 16.8.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.
@@ -639,6 +639,13 @@ async function readFileBuffer(filepath) {
639
639
  return (0, node_fs_promises.readFile)(filepath);
640
640
  }
641
641
  /**
642
+ * Read file as a buffer if it exists, otherwise return null.
643
+ */
644
+ async function readFileBufferOrNull(filepath) {
645
+ if (await fileExists(filepath)) return readFileBuffer(filepath);
646
+ return null;
647
+ }
648
+ /**
642
649
  * Normalizes text to LF line endings and adds exactly one trailing newline.
643
650
  * Removes any existing trailing whitespace and appends a single newline.
644
651
  */
@@ -650,6 +657,10 @@ async function writeFileContent(filepath, content) {
650
657
  await ensureDir((0, node_path.dirname)(filepath));
651
658
  await (0, node_fs_promises.writeFile)(filepath, content, "utf-8");
652
659
  }
660
+ async function writeFileBuffer(filepath, buffer) {
661
+ await ensureDir((0, node_path.dirname)(filepath));
662
+ await (0, node_fs_promises.writeFile)(filepath, buffer);
663
+ }
653
664
  async function fileExists(filepath) {
654
665
  try {
655
666
  await (0, node_fs_promises.stat)(filepath);
@@ -1856,7 +1867,7 @@ var RulesyncFile = class extends AiFile {
1856
1867
  * Type guard to check if a value is a plain object (Record<string, unknown>).
1857
1868
  * This excludes arrays and null values.
1858
1869
  */
1859
- function isRecord(value) {
1870
+ function isRecord$1(value) {
1860
1871
  return typeof value === "object" && value !== null && !Array.isArray(value);
1861
1872
  }
1862
1873
  /**
@@ -1871,7 +1882,7 @@ function isRecord(value) {
1871
1882
  * malicious accessor descriptors.
1872
1883
  */
1873
1884
  function isPlainObject$1(value) {
1874
- if (!isRecord(value)) return false;
1885
+ if (!isRecord$1(value)) return false;
1875
1886
  const proto = Object.getPrototypeOf(value);
1876
1887
  return proto === null || proto === Object.prototype;
1877
1888
  }
@@ -2177,11 +2188,13 @@ const HookDefinitionSchema = zod_mini.z.looseObject({
2177
2188
  timeout: zod_mini.z.optional(zod_mini.z.number()),
2178
2189
  cacheTtl: zod_mini.z.optional(zod_mini.z.number().check((0, zod_mini.nonnegative)())),
2179
2190
  matcher: zod_mini.z.optional(safeString),
2191
+ enabled: zod_mini.z.optional(zod_mini.z.boolean()),
2180
2192
  prompt: zod_mini.z.optional(safeString),
2181
2193
  loop_limit: zod_mini.z.optional(zod_mini.z.nullable(zod_mini.z.number())),
2182
2194
  name: zod_mini.z.optional(safeString),
2183
2195
  description: zod_mini.z.optional(safeString),
2184
2196
  failClosed: zod_mini.z.optional(zod_mini.z.boolean()),
2197
+ commandRegex: zod_mini.z.optional(safeString),
2185
2198
  sequential: zod_mini.z.optional(zod_mini.z.boolean()),
2186
2199
  async: zod_mini.z.optional(zod_mini.z.boolean()),
2187
2200
  env: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), safeString)),
@@ -2198,6 +2211,7 @@ const HookDefinitionSchema = zod_mini.z.looseObject({
2198
2211
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
2199
2212
  if: zod_mini.z.optional(safeString),
2200
2213
  commandWindows: zod_mini.z.optional(safeString),
2214
+ additionalContextLimit: zod_mini.z.optional(zod_mini.z.int().check((0, zod_mini.nonnegative)())),
2201
2215
  asyncRewake: zod_mini.z.optional(zod_mini.z.boolean()),
2202
2216
  continueOnBlock: zod_mini.z.optional(zod_mini.z.boolean())
2203
2217
  });
@@ -2493,8 +2507,9 @@ const FACTORYDROID_HOOK_EVENTS = [
2493
2507
  /**
2494
2508
  * Hook events supported by deepagents-cli (`deepagents-code` / `dcode`).
2495
2509
  *
2496
- * The canonical `notification` event maps to dcode's `input.required`
2497
- * (human-in-the-loop interrupt) the closest documented equivalent.
2510
+ * These are the twelve Hooks v2 `HookEvent` members, GA since deepagents-code
2511
+ * 0.1.52. Canonical `contextOffload` is deliberately absent — see
2512
+ * {@link CANONICAL_TO_DEEPAGENTS_EVENT_NAMES}.
2498
2513
  * https://docs.langchain.com/oss/python/deepagents/cli/configuration
2499
2514
  */
2500
2515
  const DEEPAGENTS_HOOK_EVENTS = [
@@ -2507,8 +2522,9 @@ const DEEPAGENTS_HOOK_EVENTS = [
2507
2522
  "postToolUseFailure",
2508
2523
  "stop",
2509
2524
  "preCompact",
2510
- "contextOffload",
2511
- "notification"
2525
+ "notification",
2526
+ "subagentStart",
2527
+ "subagentStop"
2512
2528
  ];
2513
2529
  /** Hook events supported by Codex CLI. */
2514
2530
  const CODEXCLI_HOOK_EVENTS = [
@@ -3196,26 +3212,59 @@ const CANONICAL_TO_GOOSE_EVENT_NAMES = {
3196
3212
  */
3197
3213
  const GOOSE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_GOOSE_EVENT_NAMES).map(([k, v]) => [v, k]));
3198
3214
  /**
3199
- * Map canonical camelCase event names to deepagents-cli dot-notation.
3215
+ * Map canonical camelCase event names to the deepagents-cli Hooks v2
3216
+ * `HookEvent` values.
3217
+ *
3218
+ * Hooks v2 went GA in deepagents-code 0.1.52 (2026-08-04) and replaced the
3219
+ * legacy dot-notation names (`session.start`, `tool.use`, …) with these twelve
3220
+ * PascalCase members. The legacy list format is still read, but is scheduled for
3221
+ * removal on 2026-09-01 (`_LEGACY_HOOKS_REMOVAL_DATE` in `hooks/loading.py`).
3222
+ *
3223
+ * Canonical `contextOffload` has no v2 counterpart. Its legacy event
3224
+ * (`context.offload`) is gone, and folding it onto `PreCompact` would silently
3225
+ * merge two distinct canonical events into one — so it is dropped for
3226
+ * deepagents instead, and reported by the hooks processor as an unsupported
3227
+ * event like any other.
3228
+ *
3229
+ * @see https://github.com/langchain-ai/deepagents `libs/code/deepagents_code/hooks/models/domain.py`
3200
3230
  */
3201
3231
  const CANONICAL_TO_DEEPAGENTS_EVENT_NAMES = {
3202
- sessionStart: "session.start",
3203
- sessionEnd: "session.end",
3204
- beforeSubmitPrompt: "user.prompt",
3205
- permissionRequest: "permission.request",
3206
- preToolUse: "tool.use",
3207
- postToolUse: "tool.result",
3208
- postToolUseFailure: "tool.error",
3209
- stop: "task.complete",
3210
- preCompact: "context.compact",
3211
- contextOffload: "context.offload",
3212
- notification: "input.required"
3232
+ sessionStart: "SessionStart",
3233
+ beforeSubmitPrompt: "UserPromptSubmit",
3234
+ sessionEnd: "SessionEnd",
3235
+ permissionRequest: "PermissionRequest",
3236
+ notification: "Notification",
3237
+ preToolUse: "PreToolUse",
3238
+ postToolUse: "PostToolUse",
3239
+ postToolUseFailure: "PostToolUseFailure",
3240
+ preCompact: "PreCompact",
3241
+ stop: "Stop",
3242
+ subagentStart: "SubagentStart",
3243
+ subagentStop: "SubagentStop"
3213
3244
  };
3214
3245
  /**
3215
- * Map deepagents-cli dot-notation event names to canonical camelCase.
3246
+ * Map deepagents-cli `HookEvent` values to canonical camelCase.
3216
3247
  */
3217
3248
  const DEEPAGENTS_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_DEEPAGENTS_EVENT_NAMES).map(([k, v]) => [v, k]));
3218
3249
  /**
3250
+ * The legacy dot-notation event names deepagents-cli used before Hooks v2.
3251
+ * Kept for the read-only import path so a `hooks.json` still in the old format
3252
+ * round-trips into canonical events instead of being silently discarded.
3253
+ */
3254
+ const DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES = {
3255
+ "session.start": "sessionStart",
3256
+ "session.end": "sessionEnd",
3257
+ "user.prompt": "beforeSubmitPrompt",
3258
+ "permission.request": "permissionRequest",
3259
+ "tool.use": "preToolUse",
3260
+ "tool.result": "postToolUse",
3261
+ "tool.error": "postToolUseFailure",
3262
+ "task.complete": "stop",
3263
+ "context.compact": "preCompact",
3264
+ "context.offload": "contextOffload",
3265
+ "input.required": "notification"
3266
+ };
3267
+ /**
3219
3268
  * Map canonical camelCase event names to Kiro CLI camelCase.
3220
3269
  * Kiro CLI uses its own event naming: agentSpawn, userPromptSubmit, preToolUse,
3221
3270
  * postToolUse, stop. Both `sessionEnd` and `stop` canonical events map to
@@ -3833,7 +3882,7 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3833
3882
  logger
3834
3883
  });
3835
3884
  for (const ignoredKey of MCP_IGNORED_ALIAS_SOURCE_KEYS) {
3836
- if (!isRecord(json[ignoredKey])) continue;
3885
+ if (!isRecord$1(json[ignoredKey])) continue;
3837
3886
  this.warnOncePerFile(`alias:${ignoredKey}`, `The "${ignoredKey}" block in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${MCP_BLOCK_KEY_ALIASES[ignoredKey]}" key instead.`, logger);
3838
3887
  }
3839
3888
  const toolBlockKeys = Object.keys(json).filter((key) => MCP_TOOL_BLOCK_KEYS.has(key));
@@ -3845,7 +3894,7 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3845
3894
  }));
3846
3895
  for (const blockKey of blockKeys) {
3847
3896
  const toolBlock = json[blockKey];
3848
- const toolServers = isRecord(toolBlock) && isRecord(toolBlock.mcpServers) ? toolBlock.mcpServers : void 0;
3897
+ const toolServers = isRecord$1(toolBlock) && isRecord$1(toolBlock.mcpServers) ? toolBlock.mcpServers : void 0;
3849
3898
  for (const [serverName, serverConfig] of Object.entries(toolServers ?? {})) {
3850
3899
  if (isPrototypePollutionKey(serverName)) continue;
3851
3900
  if (serverConfig === null) delete effectiveServers[serverName];
@@ -4245,7 +4294,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
4245
4294
  */
4246
4295
  const FactorydroidPermissionsOverrideSchema = zod_mini.z.looseObject({
4247
4296
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
4248
- commandBlocklist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
4297
+ commandBlocklist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
4298
+ disabledSkills: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
4249
4299
  });
4250
4300
  /**
4251
4301
  * Tool-scoped override block for Warp. Warp's `[agents.profiles]` table exposes
@@ -4691,13 +4741,22 @@ const CodexBasePermissionProfileSchema = zod_mini.z.enum(CODEX_BASE_PERMISSION_P
4691
4741
  * `base_permission_profile` it is consumed by the profile builder, not
4692
4742
  * written as a top-level config key.
4693
4743
  *
4694
- * Two surfaces are deliberately NOT authorable here so the override can never
4695
- * clobber a feature-owned key: `mcp_servers.*` per-MCP gating is owned by the
4696
- * MCP feature (`codexcli-mcp.ts` already writes the `mcp_servers` tables in the
4697
- * same `config.toml`), and `permissions` / `default_permissions` are owned by
4698
- * the canonical model. Any such key placed in the override is skipped with a
4699
- * warning. Kept `looseObject` (verbatim passthrough) so future top-level Codex
4700
- * config keys can be authored without Rulesync modeling each one.
4744
+ * The keys written to `config.toml` are an **allowlist**, not verbatim
4745
+ * passthrough: only `CODEXCLI_OVERRIDE_KEYS`
4746
+ * (`src/constants/codexcli-paths.ts` `approval_policy`, `sandbox_mode`,
4747
+ * `sandbox_workspace_write`, `apps`, `approvals_reviewer`) are emitted, and
4748
+ * `computeCodexcliOverridePatch` skips anything else with a warning.
4749
+ * `base_permission_profile` and `git_write_rules` are consumed by the profile
4750
+ * builder rather than written, as described above, and `permission` is the
4751
+ * tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
4752
+ * of the override before it ever reaches the patch. The allowlist is what keeps
4753
+ * the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
4754
+ * gating is owned by the MCP feature (`codexcli-mcp.ts` already writes the
4755
+ * `mcp_servers` tables in the same `config.toml`), and `permissions` /
4756
+ * `default_permissions` are owned by the canonical model. The schema itself is
4757
+ * `looseObject` so an unmodeled key parses (and is then reported rather than
4758
+ * rejected outright); supporting a new top-level Codex config key means adding
4759
+ * it to `CODEXCLI_OVERRIDE_KEYS`.
4701
4760
  *
4702
4761
  * @see https://developers.openai.com/codex/config-reference
4703
4762
  * @see https://developers.openai.com/codex/permissions
@@ -4852,9 +4911,9 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
4852
4911
  if (NATIVE_PERMISSION_OVERRIDE_TARGETS.has(toolTarget)) return this;
4853
4912
  const overrideKey = PERMISSION_OVERRIDE_KEY_ALIASES[toolTarget] ?? toolTarget;
4854
4913
  const json = this.json;
4855
- if (overrideKey !== toolTarget && isRecord(json[toolTarget])) logger?.warn(`The "${toolTarget}" block in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${overrideKey}" key instead (the ${toolTarget} target reads that block).`);
4914
+ if (overrideKey !== toolTarget && isRecord$1(json[toolTarget])) logger?.warn(`The "${toolTarget}" block in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${overrideKey}" key instead (the ${toolTarget} target reads that block).`);
4856
4915
  const overrideBlock = json[overrideKey];
4857
- if (!isRecord(overrideBlock) || !isRecord(overrideBlock.permission)) return this;
4916
+ if (!isRecord$1(overrideBlock) || !isRecord$1(overrideBlock.permission)) return this;
4858
4917
  const { permission: toolScopedPermission, ...restOverride } = overrideBlock;
4859
4918
  const merged = {
4860
4919
  ...json,
@@ -5164,6 +5223,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5164
5223
  compatibility: zod_mini.z.optional(zod_mini.z.looseObject({})),
5165
5224
  metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
5166
5225
  })),
5226
+ kiro: zod_mini.z.optional(zod_mini.z.looseObject({
5227
+ license: zod_mini.z.optional(zod_mini.z.string()),
5228
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
5229
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
5230
+ })),
5167
5231
  deepagents: zod_mini.z.optional(zod_mini.z.looseObject({
5168
5232
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
5169
5233
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -5172,7 +5236,11 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5172
5236
  })),
5173
5237
  copilot: zod_mini.z.optional(zod_mini.z.looseObject({
5174
5238
  license: zod_mini.z.optional(zod_mini.z.string()),
5175
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
5239
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
5240
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
5241
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5242
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5243
+ context: zod_mini.z.optional(zod_mini.z.string())
5176
5244
  })),
5177
5245
  copilotcli: zod_mini.z.optional(zod_mini.z.looseObject({
5178
5246
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -5230,7 +5298,9 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5230
5298
  })),
5231
5299
  factorydroid: zod_mini.z.optional(zod_mini.z.looseObject({
5232
5300
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5233
- "user-invocable": zod_mini.z.optional(zod_mini.z.boolean())
5301
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5302
+ enabled: zod_mini.z.optional(zod_mini.z.boolean()),
5303
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
5234
5304
  })),
5235
5305
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({
5236
5306
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
@@ -5443,8 +5513,8 @@ async function getLocalSkillDirNames(outputRoot) {
5443
5513
  * Resolve the effective `disable-model-invocation` value for a tool skill.
5444
5514
  *
5445
5515
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
5446
- * default that applies to every tool supporting the flag (claudecode, cursor,
5447
- * zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5516
+ * default that applies to every tool supporting the flag (claudecode, copilot,
5517
+ * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5448
5518
  * default with a per-target value. A defined section value (including `false`)
5449
5519
  * always wins over the root default.
5450
5520
  *
@@ -5457,8 +5527,8 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
5457
5527
  * Resolve the effective `user-invocable` value for a tool skill.
5458
5528
  *
5459
5529
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
5460
- * that applies to every tool supporting the flag (claudecode, qwencode, vibe,
5461
- * grokcli, factorydroid). Each tool's own section may override that default with a
5530
+ * that applies to every tool supporting the flag (claudecode, copilot,
5531
+ * copilotcli, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
5462
5532
  * per-target value. A defined section value (including `false`) always wins
5463
5533
  * over the root default.
5464
5534
  *
@@ -5671,6 +5741,31 @@ function fileContentsEquivalent({ filePath, expected, existing }) {
5671
5741
  if (structured !== void 0) return structured;
5672
5742
  return addTrailingNewline(expected) === addTrailingNewline(existing);
5673
5743
  }
5744
+ /**
5745
+ * Whether an on-disk companion file is equivalent to the generated one.
5746
+ *
5747
+ * Companion files (everything beside a skill's `SKILL.md`) are written byte for
5748
+ * byte, so byte equality is the whole test for a user asset carried through
5749
+ * from the source directory: a CRLF fixture or a deliberately newline-less file
5750
+ * must compare equal to itself and unequal to a normalized copy, and a copy
5751
+ * that has drifted must be repaired rather than tolerated.
5752
+ *
5753
+ * A `composed` file is different — Rulesync builds it from frontmatter (Codex
5754
+ * CLI's `agents/openai.yaml`), so differing bytes fall back to the structured
5755
+ * comparison and a formatter re-indenting it is not reported as a change on
5756
+ * every generate. Only the structured verdict counts: there is deliberately no
5757
+ * text fallback, since trailing-whitespace-insensitive text equality is exactly
5758
+ * the normalization companion files no longer get.
5759
+ */
5760
+ function companionFileContentsEquivalent({ filePath, expected, existing, composed = false }) {
5761
+ if (existing === null) return false;
5762
+ if (existing.equals(expected)) return true;
5763
+ if (!composed) return false;
5764
+ const expectedText = expected.toString("utf-8");
5765
+ const existingText = existing.toString("utf-8");
5766
+ if (!Buffer.from(expectedText, "utf-8").equals(expected) || !Buffer.from(existingText, "utf-8").equals(existing)) return false;
5767
+ return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
5768
+ }
5674
5769
  //#endregion
5675
5770
  //#region src/types/feature-processor.ts
5676
5771
  var FeatureProcessor = class {
@@ -7071,6 +7166,14 @@ const SHARED_CONFIG_OWNERSHIP = {
7071
7166
  ownedKeys: ["mcp", "tools"]
7072
7167
  } }
7073
7168
  },
7169
+ ".config/goose/config.yaml": {
7170
+ format: "yaml",
7171
+ invalidRootPolicy: "error",
7172
+ features: { mcp: {
7173
+ kind: "replace-owned-keys",
7174
+ ownedKeys: ["extensions"]
7175
+ } }
7176
+ },
7074
7177
  [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
7075
7178
  format: "toml",
7076
7179
  features: {
@@ -8219,7 +8322,7 @@ var AntigravitySharedCommand = class extends ToolCommand {
8219
8322
  }
8220
8323
  static extractAntigravityConfig(rulesyncCommand) {
8221
8324
  const antigravity = rulesyncCommand.getFrontmatter().antigravity;
8222
- return isRecord(antigravity) ? antigravity : void 0;
8325
+ return isRecord$1(antigravity) ? antigravity : void 0;
8223
8326
  }
8224
8327
  static resolveTrigger(rulesyncCommand, antigravityConfig) {
8225
8328
  const rulesyncFrontmatter = rulesyncCommand.getFrontmatter();
@@ -9309,9 +9412,10 @@ const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
9309
9412
  //#region src/features/commands/factorydroid-command.ts
9310
9413
  const FactorydroidCommandFrontmatterSchema = zod_mini.z.looseObject({
9311
9414
  description: zod_mini.z.optional(zod_mini.z.string()),
9312
- "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
9313
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
9415
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string())
9314
9416
  });
9417
+ /** Not a Droid command surface; see the schema comment above. */
9418
+ const FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS = ["allowed-tools"];
9315
9419
  var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9316
9420
  frontmatter;
9317
9421
  body;
@@ -9338,6 +9442,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9338
9442
  }
9339
9443
  toRulesyncCommand() {
9340
9444
  const { description, ...restFields } = this.frontmatter;
9445
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete restFields[field];
9341
9446
  const rulesyncFrontmatter = {
9342
9447
  targets: ["*"],
9343
9448
  description,
@@ -9361,6 +9466,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9361
9466
  description: rulesyncFrontmatter.description,
9362
9467
  ...factorydroidFields
9363
9468
  };
9469
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete factorydroidFrontmatter[field];
9364
9470
  const body = rulesyncCommand.getBody();
9365
9471
  const paths = this.getSettablePaths({ global });
9366
9472
  return new FactorydroidCommand({
@@ -12006,8 +12112,8 @@ async function lookupPromptDescription({ outputRoot, relativeFilePath, name }) {
12006
12112
  }
12007
12113
  if (!isPlainObject$1(parsed) || !Array.isArray(parsed.prompts)) return "";
12008
12114
  const expectedContentFile = toPosixPath((0, node_path.join)("prompts", relativeFilePath));
12009
- const entry = parsed.prompts.find((candidate) => isRecord(candidate) && (candidate.content_file === expectedContentFile || candidate.name === name));
12010
- return isRecord(entry) && typeof entry.description === "string" ? entry.description : "";
12115
+ const entry = parsed.prompts.find((candidate) => isRecord$1(candidate) && (candidate.content_file === expectedContentFile || candidate.name === name));
12116
+ return isRecord$1(entry) && typeof entry.description === "string" ? entry.description : "";
12011
12117
  }
12012
12118
  /**
12013
12119
  * The shared `.rovodev/prompts.yml` manifest that indexes every saved prompt.
@@ -13072,16 +13178,28 @@ function buildEffectiveHooks$1({ config, toolOverrideHooks, supportedEvents }) {
13072
13178
  };
13073
13179
  }
13074
13180
  /**
13075
- * Group a list of hook definitions by their `matcher` (empty string when absent),
13076
- * preserving insertion order of both keys and grouped definitions.
13181
+ * Group a list of hook definitions by their `matcher` (empty string when
13182
+ * absent), preserving insertion order of both keys and grouped definitions.
13183
+ * Definitions that disagree on a `subdividesGroup` passthrough field are split
13184
+ * into separate groups, so a restricting field is never inherited by a hook
13185
+ * that did not ask for it.
13077
13186
  */
13078
- function groupDefinitionsByMatcher(definitions) {
13187
+ function groupDefinitionsByMatcher({ definitions, converterConfig }) {
13188
+ const subdividingFields = (converterConfig.groupPassthroughFields ?? []).filter(({ subdividesGroup }) => subdividesGroup);
13079
13189
  const byMatcher = /* @__PURE__ */ new Map();
13080
13190
  for (const def of definitions) {
13081
- const key = def.matcher ?? "";
13082
- const list = byMatcher.get(key);
13083
- if (list) list.push(def);
13084
- else byMatcher.set(key, [def]);
13191
+ const rawMatcher = def.matcher ?? "";
13192
+ const matcher = converterConfig.wildcardMatcherMeansAll && rawMatcher === "*" ? "" : rawMatcher;
13193
+ const key = [matcher, ...subdividingFields.map(({ canonical, valueType }) => {
13194
+ const value = def[canonical];
13195
+ return isGroupPassthroughValue(value, valueType) ? stableJson(value) : "";
13196
+ })].join("\0");
13197
+ const group = byMatcher.get(key);
13198
+ if (group) group.defs.push(def);
13199
+ else byMatcher.set(key, {
13200
+ matcher,
13201
+ defs: [def]
13202
+ });
13085
13203
  }
13086
13204
  return byMatcher;
13087
13205
  }
@@ -13108,59 +13226,148 @@ function applyCommandPrefix({ def, converterConfig }) {
13108
13226
  return `"${converterConfig.projectDirVar}"/${relativeCommand}`;
13109
13227
  }
13110
13228
  /**
13111
- * Emit the configured boolean passthrough fields on the tool side, mapping each
13112
- * canonical field name to its (possibly renamed) tool field name. Only boolean
13113
- * values are carried through.
13229
+ * Whether a field registered for `command` hooks only applies to this hook.
13230
+ * Applied on both export and import: a value imported into a canonical field
13231
+ * the exporter would then drop is silently deleted on the next generate.
13114
13232
  */
13115
- function emitBooleanPassthroughFields({ def, hookType, converterConfig }) {
13116
- return Object.fromEntries((converterConfig.booleanPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13117
- if (commandOnly === true && hookType !== "command") return false;
13118
- return typeof def[canonical] === "boolean";
13119
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13233
+ function isFieldApplicable({ commandOnly, hookType }) {
13234
+ return commandOnly !== true || hookType === "command";
13120
13235
  }
13121
13236
  /**
13122
- * Import the configured boolean passthrough fields back into canonical fields,
13123
- * reversing {@link emitBooleanPassthroughFields}. Only boolean values are read.
13124
- */
13125
- function importBooleanPassthroughFields({ h, converterConfig }) {
13126
- return Object.fromEntries((converterConfig.booleanPassthroughFields ?? []).filter(({ tool }) => typeof h[tool] === "boolean").map(({ canonical, tool }) => [canonical, h[tool]]));
13127
- }
13128
- /**
13129
- * Emit the configured string passthrough fields on the tool side, mapping each
13130
- * canonical field name to its (possibly renamed) tool field name. Only non-empty
13131
- * string values are carried through.
13132
- */
13133
- function emitStringPassthroughFields({ def, hookType, converterConfig }) {
13134
- return Object.fromEntries((converterConfig.stringPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13135
- if (commandOnly === true && hookType !== "command") return false;
13136
- return typeof def[canonical] === "string" && def[canonical] !== "";
13137
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13138
- }
13139
- /**
13140
- * Import the configured string passthrough fields back into canonical fields,
13141
- * reversing {@link emitStringPassthroughFields}. Only non-empty string values
13142
- * are read.
13143
- */
13144
- function importStringPassthroughFields({ h, converterConfig }) {
13145
- return Object.fromEntries((converterConfig.stringPassthroughFields ?? []).filter(({ tool }) => typeof h[tool] === "string" && h[tool] !== "").map(({ canonical, tool }) => [canonical, h[tool]]));
13146
- }
13147
- /**
13148
- * Emit the configured string-array passthrough fields on the tool side.
13149
- */
13150
- function emitArrayPassthroughFields({ def, hookType, converterConfig }) {
13151
- return Object.fromEntries((converterConfig.arrayPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13152
- if (commandOnly === true && hookType !== "command") return false;
13153
- return isStringArray(def[canonical]);
13154
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13155
- }
13156
- /**
13157
- * Import the configured string-array passthrough fields, reversing
13158
- * {@link emitArrayPassthroughFields}.
13237
+ * Emit the configured passthrough fields on the tool side, mapping each
13238
+ * canonical field name to its (possibly renamed) tool field name. Only values
13239
+ * accepted by `isValid` are carried through, so a malformed field can't leak
13240
+ * into a config the tool would reject.
13241
+ *
13242
+ * A field the tool documents on `command` hooks only is dropped when authored
13243
+ * on another hook type. That is a value the user hand-wrote in
13244
+ * `.rulesync/hooks.*` and the canonical schema does not cross-validate it
13245
+ * against the hook's type, so it is warned about rather than deleted in
13246
+ * silence the mirror of the import side.
13159
13247
  */
13160
- function importArrayPassthroughFields({ h, converterConfig, logger }) {
13161
- const fields = converterConfig.arrayPassthroughFields ?? [];
13162
- for (const { tool } of fields) if (h[tool] !== void 0 && !isSafeStringArray(h[tool])) logger?.warn(`Dropping "${tool}" while importing a hook: it must be a list of strings without newline, carriage return or NUL characters.`);
13163
- return Object.fromEntries(fields.filter(({ tool }) => isSafeStringArray(h[tool])).map(({ canonical, tool }) => [canonical, h[tool]]));
13248
+ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn }) {
13249
+ for (const { canonical, tool, commandOnly } of fields) {
13250
+ const value = def[canonical];
13251
+ if (value === void 0) continue;
13252
+ if (!isFieldApplicable({
13253
+ commandOnly,
13254
+ hookType
13255
+ })) {
13256
+ warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": this tool documents "${tool}" on "command" hooks only, so it is not generated.`);
13257
+ continue;
13258
+ }
13259
+ if (!isValid({
13260
+ value,
13261
+ canonical
13262
+ })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${JSON.stringify(value)} is not a value this tool can express as "${tool}".`);
13263
+ }
13264
+ return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
13265
+ commandOnly,
13266
+ hookType
13267
+ }) && isValid({
13268
+ value: def[canonical],
13269
+ canonical
13270
+ })).map(({ canonical, tool }) => [tool, def[canonical]]));
13271
+ }
13272
+ /**
13273
+ * Import the configured passthrough fields back into canonical fields,
13274
+ * reversing {@link emitPassthroughFields}. A field the tool documents on
13275
+ * `command` hooks only is skipped here too, and `describeInvalid` — when the
13276
+ * kind has a rule an authored file can plausibly violate — turns a rejected
13277
+ * value into a warning instead of a silent drop.
13278
+ */
13279
+ function importPassthroughFields({ h, hookType, fields, isValid, describeInvalid, warn }) {
13280
+ const applicable = fields.filter(({ commandOnly }) => isFieldApplicable({
13281
+ commandOnly,
13282
+ hookType
13283
+ }));
13284
+ const skipped = fields.filter(({ commandOnly }) => !isFieldApplicable({
13285
+ commandOnly,
13286
+ hookType
13287
+ }));
13288
+ for (const { tool } of skipped) if (h[tool] !== void 0) warn?.(`Dropping "${tool}" from an imported "${hookType}" hook: this tool documents it on "command" hooks only, so it is not imported.`);
13289
+ for (const { tool, canonical } of applicable) if (describeInvalid !== void 0 && h[tool] !== void 0 && !isValid({
13290
+ value: h[tool],
13291
+ canonical
13292
+ })) warn?.(describeInvalid({
13293
+ tool,
13294
+ canonical,
13295
+ value: h[tool]
13296
+ }));
13297
+ return Object.fromEntries(applicable.filter(({ tool, canonical }) => isValid({
13298
+ value: h[tool],
13299
+ canonical
13300
+ })).map(({ canonical, tool }) => [canonical, h[tool]]));
13301
+ }
13302
+ const isBooleanValue = ({ value }) => typeof value === "boolean";
13303
+ const isFiniteNumber = ({ value }) => Number.isFinite(value);
13304
+ const isNonEmptyString = (value) => typeof value === "string" && value !== "";
13305
+ const isEmittableString = ({ value }) => isNonEmptyString(value);
13306
+ const isEmittableArray = ({ value }) => isStringArray(value);
13307
+ const isEmittableRecord = ({ value }) => isSafeStringRecord(value);
13308
+ const isImportableArray = ({ value }) => isSafeStringArray(value);
13309
+ /**
13310
+ * The canonical schema of one hook field, looked up by name. Read off the
13311
+ * schema's own shape rather than by parsing a one-field object, so a `canonical`
13312
+ * name that no longer exists resolves to `undefined` (a `looseObject` would
13313
+ * accept an unknown key and silently validate nothing) and a typo is caught by
13314
+ * the tests instead of quietly disabling the check.
13315
+ */
13316
+ const CANONICAL_FIELD_SCHEMAS = HookDefinitionSchema.def.shape;
13317
+ /**
13318
+ * Whether a value satisfies the constraints the canonical schema puts on the
13319
+ * field it would be imported into.
13320
+ *
13321
+ * Import needs this on top of the kind's shape check: a hand-written tool
13322
+ * settings file can hold `"shell": "zsh"`, which is a non-empty string but not
13323
+ * a member of the canonical `z.enum(["bash", "powershell"])`. Letting it in
13324
+ * would write a `.rulesync/hooks.jsonc` that fails validation on the *next*
13325
+ * run, taking the whole hooks feature down with it.
13326
+ */
13327
+ function satisfiesCanonicalField({ value, canonical }) {
13328
+ const schema = canonicalFieldSchema(canonical);
13329
+ return schema !== void 0 && zod_mini.z.safeParse(schema, value).success;
13330
+ }
13331
+ /** Own properties only, so a name like `toString` resolves to nothing. */
13332
+ function canonicalFieldSchema(canonical) {
13333
+ return Object.hasOwn(CANONICAL_FIELD_SCHEMAS, canonical) ? CANONICAL_FIELD_SCHEMAS[canonical] : void 0;
13334
+ }
13335
+ const isImportableString = ({ value, canonical }) => isNonEmptyString(value) && satisfiesCanonicalField({
13336
+ value,
13337
+ canonical
13338
+ });
13339
+ const isImportableNumber = ({ value, canonical }) => Number.isFinite(value) && satisfiesCanonicalField({
13340
+ value,
13341
+ canonical
13342
+ });
13343
+ /**
13344
+ * Say which rule the value broke, so the warning names the actual constraint
13345
+ * rather than asserting a canonical rejection that may not be the reason. A
13346
+ * closed enum lists its members; a rule carrying its own message (the
13347
+ * control-character check behind `safeString`) reuses it.
13348
+ */
13349
+ function describeScalarConstraint({ canonical, value }) {
13350
+ const schema = canonicalFieldSchema(canonical);
13351
+ const result = schema === void 0 ? void 0 : zod_mini.z.safeParse(schema, value);
13352
+ if (result === void 0 || result.success) return `it is not a value this field carries through.`;
13353
+ const issue = result.error.issues[0];
13354
+ if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
13355
+ return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
13356
+ }
13357
+ const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${JSON.stringify(value)}) while importing a hook: ${describeScalarConstraint({
13358
+ canonical,
13359
+ value
13360
+ })} Importing it would fail validation on the next run.`;
13361
+ const describeInvalidArray = ({ tool }) => `Dropping "${tool}" while importing a hook: it must be a list of strings without newline, carriage return or NUL characters.`;
13362
+ const describeInvalidRecord = ({ tool }) => `Dropping "${tool}" while importing a hook: it must be a map of strings whose keys are non-empty and free of "=", and whose keys and values carry no newline, carriage return or NUL characters.`;
13363
+ /**
13364
+ * Check a value against the shape its field documents. A string field also
13365
+ * rejects control characters, matching the canonical `safeString` so an
13366
+ * imported value cannot fail validation on the next generate.
13367
+ */
13368
+ function isGroupPassthroughValue(value, valueType = "object") {
13369
+ if (valueType === "string") return typeof value === "string" && !CONTROL_CHARS.some((char) => value.includes(char));
13370
+ return isPlainObject$1(value);
13164
13371
  }
13165
13372
  /**
13166
13373
  * Emit the configured group-level passthrough fields, taken from the first
@@ -13168,12 +13375,12 @@ function importArrayPassthroughFields({ h, converterConfig, logger }) {
13168
13375
  */
13169
13376
  function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }) {
13170
13377
  const emitted = {};
13171
- for (const { canonical, tool } of converterConfig.groupPassthroughFields ?? []) {
13378
+ for (const { canonical, tool, valueType } of converterConfig.groupPassthroughFields ?? []) {
13172
13379
  const carried = defs.map((def) => def[canonical]);
13173
- const first = carried.find((value) => isPlainObject$1(value));
13380
+ const first = carried.find((value) => isGroupPassthroughValue(value, valueType));
13174
13381
  if (first === void 0) continue;
13175
13382
  const firstStable = stableJson(first);
13176
- const agrees = (value) => isPlainObject$1(value) && stableJson(value) === firstStable;
13383
+ const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
13177
13384
  if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${JSON.stringify(first)} — including any that asked for something else, or for nothing.`);
13178
13385
  emitted[tool] = first;
13179
13386
  }
@@ -13185,7 +13392,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
13185
13392
  */
13186
13393
  function importGroupPassthroughFields({ rawEntry, converterConfig }) {
13187
13394
  const entry = rawEntry;
13188
- return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool }) => isPlainObject$1(entry[tool])).map(({ canonical, tool }) => [canonical, entry[tool]]));
13395
+ return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool, valueType }) => isGroupPassthroughValue(entry[tool], valueType)).map(({ canonical, tool }) => [canonical, entry[tool]]));
13189
13396
  }
13190
13397
  /**
13191
13398
  * Emit the payload fields specific to a hook type — `url`/`headers`/
@@ -13213,7 +13420,54 @@ function emitTypePayloadFields({ def, hookType, converterConfig }) {
13213
13420
  function isSupportedHookType({ type, converterConfig }) {
13214
13421
  return converterConfig.supportedHookTypes?.has(type ?? "command") ?? true;
13215
13422
  }
13216
- function buildToolHooks({ defs, converterConfig }) {
13423
+ /**
13424
+ * Emit every per-hook passthrough kind for one canonical definition.
13425
+ */
13426
+ function emitAllPassthroughFields({ def, hookType, eventName, converterConfig, warn }) {
13427
+ return {
13428
+ ...emitPassthroughFields({
13429
+ def,
13430
+ hookType,
13431
+ eventName,
13432
+ fields: converterConfig.booleanPassthroughFields ?? [],
13433
+ isValid: isBooleanValue,
13434
+ warn
13435
+ }),
13436
+ ...emitPassthroughFields({
13437
+ def,
13438
+ hookType,
13439
+ eventName,
13440
+ fields: converterConfig.numberPassthroughFields ?? [],
13441
+ isValid: isFiniteNumber,
13442
+ warn
13443
+ }),
13444
+ ...emitPassthroughFields({
13445
+ def,
13446
+ hookType,
13447
+ eventName,
13448
+ fields: converterConfig.stringPassthroughFields ?? [],
13449
+ isValid: isEmittableString,
13450
+ warn
13451
+ }),
13452
+ ...emitPassthroughFields({
13453
+ def,
13454
+ hookType,
13455
+ eventName,
13456
+ fields: converterConfig.arrayPassthroughFields ?? [],
13457
+ isValid: isEmittableArray,
13458
+ warn
13459
+ }),
13460
+ ...emitPassthroughFields({
13461
+ def,
13462
+ hookType,
13463
+ eventName,
13464
+ fields: converterConfig.recordPassthroughFields ?? [],
13465
+ isValid: isEmittableRecord,
13466
+ warn
13467
+ })
13468
+ };
13469
+ }
13470
+ function buildToolHooks({ defs, eventName, converterConfig, warn }) {
13217
13471
  const hooks = [];
13218
13472
  for (const def of defs) {
13219
13473
  const hookType = def.type ?? "command";
@@ -13226,20 +13480,12 @@ function buildToolHooks({ defs, converterConfig }) {
13226
13480
  converterConfig
13227
13481
  });
13228
13482
  hooks.push({
13229
- ...emitBooleanPassthroughFields({
13483
+ ...emitAllPassthroughFields({
13230
13484
  def,
13231
13485
  hookType,
13232
- converterConfig
13233
- }),
13234
- ...emitStringPassthroughFields({
13235
- def,
13236
- hookType,
13237
- converterConfig
13238
- }),
13239
- ...emitArrayPassthroughFields({
13240
- def,
13241
- hookType,
13242
- converterConfig
13486
+ eventName,
13487
+ converterConfig,
13488
+ warn
13243
13489
  }),
13244
13490
  type: hookType,
13245
13491
  ...command !== void 0 && command !== null && { command },
@@ -13257,6 +13503,17 @@ function buildToolHooks({ defs, converterConfig }) {
13257
13503
  return hooks;
13258
13504
  }
13259
13505
  /**
13506
+ * A `warn` that says each distinct thing once per conversion.
13507
+ */
13508
+ function warnOnce(logger) {
13509
+ const seen = /* @__PURE__ */ new Set();
13510
+ return (message) => {
13511
+ if (seen.has(message)) return;
13512
+ seen.add(message);
13513
+ logger?.warn(message);
13514
+ };
13515
+ }
13516
+ /**
13260
13517
  * Convert canonical hooks config to tool-specific format (shared by Claude and Factory Droid).
13261
13518
  * Uses explicit event name mapping tables rather than algorithmic case conversion,
13262
13519
  * since tool event names may differ entirely from canonical names
@@ -13268,17 +13525,23 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
13268
13525
  toolOverrideHooks,
13269
13526
  supportedEvents: converterConfig.supportedEvents
13270
13527
  });
13528
+ const warn = warnOnce(logger);
13271
13529
  const result = {};
13272
13530
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
13273
13531
  const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
13274
- const byMatcher = groupDefinitionsByMatcher(definitions);
13532
+ const byMatcher = groupDefinitionsByMatcher({
13533
+ definitions,
13534
+ converterConfig
13535
+ });
13275
13536
  const entries = [];
13276
13537
  const isNoMatcherEvent = converterConfig.noMatcherEvents?.has(eventName) ?? false;
13277
- for (const [matcherKey, defs] of byMatcher) {
13538
+ for (const { matcher: matcherKey, defs } of byMatcher.values()) {
13278
13539
  if (isNoMatcherEvent && matcherKey) logger?.warn(`matcher "${matcherKey}" on "${eventName}" hook will be ignored — this event does not support matchers`);
13279
13540
  const hooks = buildToolHooks({
13280
13541
  defs,
13281
- converterConfig
13542
+ eventName,
13543
+ converterConfig,
13544
+ warn
13282
13545
  });
13283
13546
  if (hooks.length === 0) continue;
13284
13547
  const includeMatcher = matcherKey && !isNoMatcherEvent;
@@ -13354,9 +13617,27 @@ function isStringArray(value) {
13354
13617
  }
13355
13618
  /** Compare object values without letting key order decide the answer. */
13356
13619
  function stableJson(value) {
13620
+ if (typeof value === "string") return JSON.stringify(value);
13357
13621
  return JSON.stringify(Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b))));
13358
13622
  }
13359
13623
  /**
13624
+ * A string map safe to hand a tool as a hook's environment block. On top of
13625
+ * {@link isStringRecord} it rejects a non-plain object (a class instance is not
13626
+ * data) and applies the control-character rule to the values, as
13627
+ * {@link isSafeStringArray} does for `args`.
13628
+ *
13629
+ * The keys are checked more strictly than the values. A tool builds each entry
13630
+ * back into a `KEY=VALUE` string for the spawned process, so a key holding `=`
13631
+ * (or a control character, or nothing at all) names a different variable than
13632
+ * it appears to — `PATH=/tmp/evil` written as a key would set `PATH`. An
13633
+ * authored `.rulesync/hooks.*` can arrive via `rulesync fetch`, so that is not
13634
+ * a shape to pass along.
13635
+ */
13636
+ function isSafeStringRecord(value) {
13637
+ if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
13638
+ return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
13639
+ }
13640
+ /**
13360
13641
  * Control characters cannot ride from an existing tool config into a canonical
13361
13642
  * field the schema guards with `safeString`, or the next generate fails
13362
13643
  * validation on a file this import itself wrote — and the hooks feature is
@@ -13366,34 +13647,152 @@ function isSafeStringArray(value) {
13366
13647
  return isStringArray(value) && value.every((entry) => !CONTROL_CHARS.some((char) => entry.includes(char)));
13367
13648
  }
13368
13649
  /**
13650
+ * A raw string kept only if the canonical field it would land in accepts it,
13651
+ * warning when it does not. The canonical string fields are guarded by
13652
+ * `safeString`, so an existing tool config carrying a control character would
13653
+ * otherwise be imported into a file the next generate refuses to read.
13654
+ */
13655
+ function importCanonicalString({ value, canonical, warn }) {
13656
+ if (typeof value !== "string") return;
13657
+ if (satisfiesCanonicalField({
13658
+ value,
13659
+ canonical
13660
+ })) return value;
13661
+ warn?.(describeInvalidScalar({
13662
+ tool: canonical,
13663
+ canonical,
13664
+ value
13665
+ }));
13666
+ }
13667
+ /**
13369
13668
  * Import the payload fields specific to a hook type, type-checking each raw
13370
13669
  * value before it enters the canonical definition.
13371
13670
  */
13372
- function importTypePayloadFields({ h, hookType }) {
13373
- if (hookType === "http") return {
13374
- ...typeof h.url === "string" && { url: h.url },
13375
- ...isStringRecord(h.headers) && { headers: h.headers },
13376
- ...isStringArray(h.allowedEnvVars) && { allowedEnvVars: h.allowedEnvVars }
13377
- };
13378
- if (hookType === "mcp_tool") return {
13379
- ...typeof h.server === "string" && { server: h.server },
13380
- ...typeof h.tool === "string" && { tool: h.tool },
13381
- ...h.input !== null && typeof h.input === "object" && !Array.isArray(h.input) && { input: h.input }
13382
- };
13383
- if (hookType === "prompt" || hookType === "agent") return typeof h.model === "string" ? { model: h.model } : {};
13671
+ function importTypePayloadFields({ h, hookType, warn }) {
13672
+ if (hookType === "http") {
13673
+ const url = importCanonicalString({
13674
+ value: h.url,
13675
+ canonical: "url",
13676
+ warn
13677
+ });
13678
+ const headers = isStringRecord(h.headers) && !satisfiesCanonicalField({
13679
+ value: h.headers,
13680
+ canonical: "headers"
13681
+ }) ? (warn?.(describeInvalidScalar({
13682
+ tool: "headers",
13683
+ canonical: "headers",
13684
+ value: h.headers
13685
+ })), void 0) : h.headers;
13686
+ return {
13687
+ ...url !== void 0 && { url },
13688
+ ...isStringRecord(headers) && { headers },
13689
+ ...isStringArray(h.allowedEnvVars) && { allowedEnvVars: h.allowedEnvVars }
13690
+ };
13691
+ }
13692
+ if (hookType === "mcp_tool") {
13693
+ const server = importCanonicalString({
13694
+ value: h.server,
13695
+ canonical: "server",
13696
+ warn
13697
+ });
13698
+ const tool = importCanonicalString({
13699
+ value: h.tool,
13700
+ canonical: "tool",
13701
+ warn
13702
+ });
13703
+ return {
13704
+ ...server !== void 0 && { server },
13705
+ ...tool !== void 0 && { tool },
13706
+ ...h.input !== null && typeof h.input === "object" && !Array.isArray(h.input) && { input: h.input }
13707
+ };
13708
+ }
13709
+ if (hookType === "prompt" || hookType === "agent") {
13710
+ const model = importCanonicalString({
13711
+ value: h.model,
13712
+ canonical: "model",
13713
+ warn
13714
+ });
13715
+ return model !== void 0 ? { model } : {};
13716
+ }
13384
13717
  return {};
13385
13718
  }
13386
13719
  /**
13720
+ * Import every per-hook passthrough kind for one tool hook record, reversing
13721
+ * the emit side in {@link buildToolHooks}.
13722
+ */
13723
+ function importAllPassthroughFields({ h, hookType, converterConfig, warn }) {
13724
+ return {
13725
+ ...importPassthroughFields({
13726
+ h,
13727
+ hookType,
13728
+ fields: converterConfig.booleanPassthroughFields ?? [],
13729
+ isValid: isBooleanValue,
13730
+ warn
13731
+ }),
13732
+ ...importPassthroughFields({
13733
+ h,
13734
+ hookType,
13735
+ fields: converterConfig.numberPassthroughFields ?? [],
13736
+ isValid: isImportableNumber,
13737
+ describeInvalid: describeInvalidScalar,
13738
+ warn
13739
+ }),
13740
+ ...importPassthroughFields({
13741
+ h,
13742
+ hookType,
13743
+ fields: converterConfig.stringPassthroughFields ?? [],
13744
+ isValid: isImportableString,
13745
+ describeInvalid: describeInvalidScalar,
13746
+ warn
13747
+ }),
13748
+ ...importPassthroughFields({
13749
+ h,
13750
+ hookType,
13751
+ fields: converterConfig.arrayPassthroughFields ?? [],
13752
+ isValid: isImportableArray,
13753
+ describeInvalid: describeInvalidArray,
13754
+ warn
13755
+ }),
13756
+ ...importPassthroughFields({
13757
+ h,
13758
+ hookType,
13759
+ fields: converterConfig.recordPassthroughFields ?? [],
13760
+ isValid: isEmittableRecord,
13761
+ describeInvalid: describeInvalidRecord,
13762
+ warn
13763
+ })
13764
+ };
13765
+ }
13766
+ /**
13387
13767
  * Convert a single tool hook record into a canonical hook definition.
13388
13768
  */
13389
- function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13390
- const command = stripCommandPrefix({
13391
- command: h.command,
13392
- converterConfig
13393
- });
13769
+ function toolHookToCanonical({ h, rawEntry, converterConfig, warn }) {
13394
13770
  const hookType = isImportedHookType(h.type) ? h.type : "command";
13771
+ const command = importCanonicalString({
13772
+ value: stripCommandPrefix({
13773
+ command: h.command,
13774
+ converterConfig
13775
+ }),
13776
+ canonical: "command",
13777
+ warn
13778
+ });
13395
13779
  const timeout = typeof h.timeout === "number" ? h.timeout : void 0;
13396
- const prompt = typeof h.prompt === "string" ? h.prompt : void 0;
13780
+ const prompt = importCanonicalString({
13781
+ value: h.prompt,
13782
+ canonical: "prompt",
13783
+ warn
13784
+ });
13785
+ const name = importCanonicalString({
13786
+ value: h.name,
13787
+ canonical: "name",
13788
+ warn
13789
+ });
13790
+ const description = importCanonicalString({
13791
+ value: h.description,
13792
+ canonical: "description",
13793
+ warn
13794
+ });
13795
+ const matcher = rawEntry.matcher;
13397
13796
  return {
13398
13797
  type: hookType,
13399
13798
  ...command !== void 0 && command !== null && { command },
@@ -13401,40 +13800,130 @@ function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13401
13800
  ...prompt !== void 0 && prompt !== null && { prompt },
13402
13801
  ...importTypePayloadFields({
13403
13802
  h,
13404
- hookType
13803
+ hookType,
13804
+ warn
13405
13805
  }),
13406
- ...converterConfig.passthroughFields?.includes("name") && typeof h.name === "string" && { name: h.name },
13407
- ...converterConfig.passthroughFields?.includes("description") && typeof h.description === "string" && { description: h.description },
13408
- ...importBooleanPassthroughFields({
13409
- h,
13410
- converterConfig
13411
- }),
13412
- ...importStringPassthroughFields({
13413
- h,
13414
- converterConfig
13415
- }),
13416
- ...importArrayPassthroughFields({
13806
+ ...converterConfig.passthroughFields?.includes("name") && name !== void 0 && { name },
13807
+ ...converterConfig.passthroughFields?.includes("description") && description !== void 0 && { description },
13808
+ ...importAllPassthroughFields({
13417
13809
  h,
13810
+ hookType,
13418
13811
  converterConfig,
13419
- logger
13812
+ warn
13420
13813
  }),
13421
13814
  ...importGroupPassthroughFields({
13422
13815
  rawEntry,
13423
13816
  converterConfig
13424
13817
  }),
13425
- ...rawEntry.matcher !== void 0 && rawEntry.matcher !== null && rawEntry.matcher !== "" && { matcher: rawEntry.matcher }
13818
+ ...matcher !== void 0 && matcher !== null && matcher !== "" && { matcher }
13426
13819
  };
13427
13820
  }
13428
13821
  /**
13429
- * Convert a single tool matcher entry into canonical hook definitions.
13822
+ * The fields whose value decides what a hook *is*, listed per hook type. When
13823
+ * one of them cannot be imported, dropping just the field would leave
13824
+ * something worse than nothing: a hook that loses its body runs nothing, and
13825
+ * one that loses its `matcher` fires on *everything* — a silent widening of
13826
+ * what the imported rule does. So the whole definition is skipped instead,
13827
+ * with the reason named. A field that does not define *this* type (a `prompt`
13828
+ * left on a command hook) is not one of them: it is dropped on its own, the
13829
+ * way any other unusable field is.
13830
+ */
13831
+ function definingFields({ h, rawEntry, hookType, converterConfig }) {
13832
+ const fields = [{
13833
+ field: "matcher",
13834
+ value: rawEntry.matcher
13835
+ }];
13836
+ if (hookType === "command") fields.push({
13837
+ field: "command",
13838
+ value: typeof h.command === "string" ? stripCommandPrefix({
13839
+ command: h.command,
13840
+ converterConfig
13841
+ }) : h.command
13842
+ });
13843
+ if (hookType === "prompt" || hookType === "agent") fields.push({
13844
+ field: "prompt",
13845
+ value: h.prompt
13846
+ });
13847
+ if (hookType === "http") fields.push({
13848
+ field: "url",
13849
+ value: h.url
13850
+ });
13851
+ if (hookType === "mcp_tool") fields.push({
13852
+ field: "server",
13853
+ value: h.server
13854
+ }, {
13855
+ field: "tool",
13856
+ value: h.tool
13857
+ });
13858
+ return fields;
13859
+ }
13860
+ /**
13861
+ * Why no hook of this matcher group can be imported, or `undefined` when they
13862
+ * can. A group field that *restricts* when its hooks run (`subdividesGroup`)
13863
+ * is the group-level twin of `matcher`: importing the group without it would
13864
+ * widen every hook in it, so the group is skipped instead.
13430
13865
  */
13431
- function toolMatcherEntryToCanonical({ rawEntry, converterConfig, logger }) {
13432
- return (rawEntry.hooks ?? []).map((h) => toolHookToCanonical({
13866
+ function describeGroupSkipReason({ rawEntry, converterConfig }) {
13867
+ const entry = rawEntry;
13868
+ for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
13869
+ const value = entry[tool];
13870
+ if (subdividesGroup !== true || value === void 0) continue;
13871
+ if (!isGroupPassthroughValue(value, valueType)) return `Skipping the hooks of a matcher group while importing: its "${tool}" (${JSON.stringify(value)}) is unusable, and these hooks run only where it matches. Importing them without it would widen when they fire, so they are skipped.`;
13872
+ }
13873
+ }
13874
+ /**
13875
+ * Why this hook cannot be imported, or `undefined` when it can.
13876
+ */
13877
+ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
13878
+ for (const { field, value } of definingFields({
13433
13879
  h,
13434
13880
  rawEntry,
13435
- converterConfig,
13436
- logger
13437
- }));
13881
+ hookType,
13882
+ converterConfig
13883
+ })) {
13884
+ if (value === void 0 || satisfiesCanonicalField({
13885
+ value,
13886
+ canonical: field
13887
+ })) continue;
13888
+ return `Skipping a hook while importing: its "${field}" (${JSON.stringify(value)}) is unusable — ${describeScalarConstraint({
13889
+ canonical: field,
13890
+ value
13891
+ })} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
13892
+ }
13893
+ }
13894
+ /**
13895
+ * Convert a single tool matcher entry into canonical hook definitions.
13896
+ */
13897
+ function toolMatcherEntryToCanonical({ rawEntry, converterConfig, warn }) {
13898
+ const hookDefs = rawEntry.hooks ?? [];
13899
+ const groupSkipReason = describeGroupSkipReason({
13900
+ rawEntry,
13901
+ converterConfig
13902
+ });
13903
+ if (groupSkipReason !== void 0) {
13904
+ warn?.(groupSkipReason);
13905
+ return [];
13906
+ }
13907
+ const definitions = [];
13908
+ for (const h of hookDefs) {
13909
+ const skipReason = describeHookSkipReason({
13910
+ h,
13911
+ rawEntry,
13912
+ hookType: isImportedHookType(h.type) ? h.type : "command",
13913
+ converterConfig
13914
+ });
13915
+ if (skipReason !== void 0) {
13916
+ warn?.(skipReason);
13917
+ continue;
13918
+ }
13919
+ definitions.push(toolHookToCanonical({
13920
+ h,
13921
+ rawEntry,
13922
+ converterConfig,
13923
+ warn
13924
+ }));
13925
+ }
13926
+ return definitions;
13438
13927
  }
13439
13928
  /**
13440
13929
  * Assemble the canonical hooks config a tool importer writes to
@@ -13464,6 +13953,7 @@ function buildImportedHooksConfig({ hooks, overrideKey, version = 1, extraOverri
13464
13953
  }
13465
13954
  function toolHooksToCanonical({ hooks, converterConfig, logger }) {
13466
13955
  if (hooks === null || hooks === void 0 || typeof hooks !== "object") return {};
13956
+ const warn = warnOnce(logger);
13467
13957
  const canonical = {};
13468
13958
  for (const [toolEventName, matcherEntries] of Object.entries(hooks)) {
13469
13959
  const eventName = converterConfig.toolToCanonicalEventNames[toolEventName] ?? toolEventName;
@@ -13474,7 +13964,7 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
13474
13964
  defs.push(...toolMatcherEntryToCanonical({
13475
13965
  rawEntry,
13476
13966
  converterConfig,
13477
- logger
13967
+ warn
13478
13968
  }));
13479
13969
  }
13480
13970
  if (defs.length > 0) canonical[eventName] = defs;
@@ -13513,7 +14003,9 @@ const ANTIGRAVITY_HOOK_NAME = "rulesync";
13513
14003
  * map for import. Accepts both the documented named-hook shape
13514
14004
  * (`{ "<name>": { "<Event>": [...], "enabled"?: bool } }`) and a legacy flat
13515
14005
  * shape (`{ "<Event>": [...] }`) so older or hand-written files still import.
13516
- * The per-hook `enabled` flag is ignored (canonical hooks have no equivalent).
14006
+ * The per-hook `enabled` flag is ignored. The canonical `enabled` field is a
14007
+ * property of a single hook definition, whereas Antigravity's flag gates a whole
14008
+ * named group, so the two do not map onto each other.
13517
14009
  */
13518
14010
  function flattenAntigravityHooks(parsed) {
13519
14011
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -13595,7 +14087,7 @@ var AntigravityHooks = class extends ToolHooks {
13595
14087
  validate
13596
14088
  });
13597
14089
  }
13598
- toRulesyncHooks() {
14090
+ toRulesyncHooks({ logger } = {}) {
13599
14091
  let parsed;
13600
14092
  try {
13601
14093
  parsed = JSON.parse(this.getFileContent());
@@ -13604,7 +14096,8 @@ var AntigravityHooks = class extends ToolHooks {
13604
14096
  }
13605
14097
  const hooks = toolHooksToCanonical({
13606
14098
  hooks: flattenAntigravityHooks(parsed),
13607
- converterConfig: ANTIGRAVITY_CONVERTER_CONFIG
14099
+ converterConfig: ANTIGRAVITY_CONVERTER_CONFIG,
14100
+ logger
13608
14101
  });
13609
14102
  const overrideKey = this.constructor.getOverrideKey();
13610
14103
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
@@ -13935,6 +14428,14 @@ var ClaudecodeHooks = class extends ToolHooks {
13935
14428
  isDeletable() {
13936
14429
  return false;
13937
14430
  }
14431
+ /**
14432
+ * The converter config used for both directions. Exposed as a static hook so
14433
+ * plugin-scoped subclasses can swap tool-specific details (e.g. the project
14434
+ * directory variable) without duplicating the rest of the config.
14435
+ */
14436
+ static getConverterConfig() {
14437
+ return CLAUDE_CONVERTER_CONFIG;
14438
+ }
13938
14439
  static getSettablePaths(_options = {}) {
13939
14440
  return {
13940
14441
  relativeDirPath: CLAUDECODE_DIR,
@@ -13960,7 +14461,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13960
14461
  const claudeHooks = canonicalToToolHooks({
13961
14462
  config,
13962
14463
  toolOverrideHooks: config.claudecode?.hooks,
13963
- converterConfig: CLAUDE_CONVERTER_CONFIG,
14464
+ converterConfig: this.getConverterConfig(),
13964
14465
  logger
13965
14466
  });
13966
14467
  const fileContent = applySharedConfigPatch({
@@ -13978,7 +14479,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13978
14479
  validate
13979
14480
  });
13980
14481
  }
13981
- toRulesyncHooks() {
14482
+ toRulesyncHooks({ logger } = {}) {
13982
14483
  let settings;
13983
14484
  try {
13984
14485
  settings = JSON.parse(this.getFileContent());
@@ -13987,7 +14488,8 @@ var ClaudecodeHooks = class extends ToolHooks {
13987
14488
  }
13988
14489
  const hooks = toolHooksToCanonical({
13989
14490
  hooks: settings.hooks,
13990
- converterConfig: CLAUDE_CONVERTER_CONFIG
14491
+ converterConfig: this.constructor.getConverterConfig(),
14492
+ logger
13991
14493
  });
13992
14494
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
13993
14495
  hooks,
@@ -14016,6 +14518,21 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
14016
14518
  isDeletable() {
14017
14519
  return true;
14018
14520
  }
14521
+ /**
14522
+ * Plugin hook scripts ship inside the plugin, so their commands must resolve
14523
+ * against the plugin install directory rather than the consumer's project
14524
+ * root. Upstream documents `"${CLAUDE_PLUGIN_ROOT}"/scripts/format-code.sh`;
14525
+ * `$CLAUDE_PROJECT_DIR` would expand to a path in the consumer's own repo,
14526
+ * where the bundled script does not exist.
14527
+ *
14528
+ * @see https://code.claude.com/docs/en/plugins-reference
14529
+ */
14530
+ static getConverterConfig() {
14531
+ return {
14532
+ ...super.getConverterConfig(),
14533
+ projectDirVar: "$CLAUDE_PLUGIN_ROOT"
14534
+ };
14535
+ }
14019
14536
  static getSettablePaths() {
14020
14537
  return {
14021
14538
  relativeDirPath: CLAUDECODE_PLUGIN_HOOKS_DIR,
@@ -14038,6 +14555,10 @@ const CODEXCLI_CONVERTER_CONFIG = {
14038
14555
  }, {
14039
14556
  canonical: "statusMessage",
14040
14557
  tool: "statusMessage"
14558
+ }],
14559
+ numberPassthroughFields: [{
14560
+ canonical: "additionalContextLimit",
14561
+ tool: "additionalContextLimit"
14041
14562
  }]
14042
14563
  };
14043
14564
  /**
@@ -14127,13 +14648,14 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14127
14648
  validate
14128
14649
  });
14129
14650
  }
14130
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
14651
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
14131
14652
  const paths = CodexcliHooks.getSettablePaths({ global });
14132
14653
  const config = rulesyncHooks.getJson();
14133
14654
  const codexHooks = canonicalToToolHooks({
14134
14655
  config,
14135
14656
  toolOverrideHooks: config.codexcli?.hooks,
14136
- converterConfig: CODEXCLI_CONVERTER_CONFIG
14657
+ converterConfig: CODEXCLI_CONVERTER_CONFIG,
14658
+ logger
14137
14659
  });
14138
14660
  const fileContent = JSON.stringify({ hooks: codexHooks }, null, 2);
14139
14661
  return new CodexcliHooks({
@@ -14144,7 +14666,7 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14144
14666
  validate
14145
14667
  });
14146
14668
  }
14147
- toRulesyncHooks() {
14669
+ toRulesyncHooks({ logger } = {}) {
14148
14670
  let parsed;
14149
14671
  try {
14150
14672
  parsed = JSON.parse(this.getFileContent());
@@ -14153,7 +14675,8 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14153
14675
  }
14154
14676
  const hooks = toolHooksToCanonical({
14155
14677
  hooks: parsed.hooks,
14156
- converterConfig: CODEXCLI_CONVERTER_CONFIG
14678
+ converterConfig: CODEXCLI_CONVERTER_CONFIG,
14679
+ logger
14157
14680
  });
14158
14681
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
14159
14682
  hooks,
@@ -14392,13 +14915,13 @@ var CopilotHooks = class CopilotHooks extends ToolHooks {
14392
14915
  * under `.github/hooks/` is picked up automatically when the CLI is
14393
14916
  * invoked from the project root.
14394
14917
  *
14395
- * - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — chosen for
14396
- * consistency with the existing global Copilot CLI config layout (e.g.
14397
- * `~/.copilot/mcp-config.json` produced by `copilotcli-mcp.ts`). The
14398
- * official docs do not currently document a global hooks location, so
14399
- * this is a rulesync convention pending official documentation; we keep
14400
- * all rulesync-managed Copilot CLI files under the single `~/.copilot/`
14401
- * root and will revisit if the spec later mandates an alternate layout.
14918
+ * - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — the directory is
14919
+ * the documented user-level hooks location ("`*.json` files in the
14920
+ * user-level hooks directory. By default this is `~/.copilot/hooks/` on
14921
+ * macOS and Linux, or `%USERPROFILE%\.copilot\hooks\` on Windows"). Every
14922
+ * `*.json` in it is loaded, so the filename remains rulesync's choice, as it
14923
+ * is for project scope. `COPILOT_HOME` relocates the directory upstream
14924
+ * (`$COPILOT_HOME/hooks/`); rulesync does not read that variable yet.
14402
14925
  *
14403
14926
  * Hook entries on the six matcher-aware events (see
14404
14927
  * {@link COPILOTCLI_MATCHER_EVENTS}) may carry an optional `matcher` regex; it
@@ -14809,17 +15332,18 @@ const DEEPAGENTS_MCP_FILE_NAME = ".mcp.json";
14809
15332
  const DEEPAGENTS_HOOKS_FILE_NAME = "hooks.json";
14810
15333
  //#endregion
14811
15334
  //#region src/features/hooks/deepagents-hooks.ts
14812
- function isDeepagentsHooksFile(val) {
14813
- if (typeof val !== "object" || val === null || !("hooks" in val)) return false;
14814
- return Array.isArray(val.hooks);
15335
+ function isRecord(value) {
15336
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14815
15337
  }
14816
15338
  /**
14817
- * Convert canonical hooks config to deepagents flat array format.
15339
+ * Convert the canonical hooks config to the deepagents Hooks v2 document.
14818
15340
  *
14819
- * deepagents format:
14820
- * { "hooks": [{ "command": ["bash", "-c", "..."], "events": ["session.start"] }] }
15341
+ * ```json
15342
+ * { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "" }] }] } }
15343
+ * ```
14821
15344
  *
14822
- * Each canonical hook definition becomes one deepagents hook entry.
15345
+ * Definitions sharing an event and matcher land in one group, preserving their
15346
+ * authored order — upstream runs a group's handlers in sequence.
14823
15347
  */
14824
15348
  function canonicalToDeepagentsHooks(config) {
14825
15349
  const supported = new Set(DEEPAGENTS_HOOK_EVENTS);
@@ -14827,7 +15351,7 @@ function canonicalToDeepagentsHooks(config) {
14827
15351
  ...config.hooks,
14828
15352
  ...config.deepagents?.hooks
14829
15353
  };
14830
- const entries = [];
15354
+ const hooks = {};
14831
15355
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
14832
15356
  if (!supported.has(canonicalEvent)) continue;
14833
15357
  const deepagentsEvent = CANONICAL_TO_DEEPAGENTS_EVENT_NAMES[canonicalEvent];
@@ -14835,43 +15359,69 @@ function canonicalToDeepagentsHooks(config) {
14835
15359
  for (const def of definitions) {
14836
15360
  if ((def.type ?? "command") !== "command") continue;
14837
15361
  if (!def.command) continue;
14838
- if (def.matcher) continue;
14839
- entries.push({
14840
- command: [
14841
- "bash",
14842
- "-c",
14843
- def.command
14844
- ],
14845
- events: [deepagentsEvent]
15362
+ const handler = {
15363
+ type: "command",
15364
+ command: def.command
15365
+ };
15366
+ if (def.timeout !== void 0 && def.timeout !== null && def.timeout > 0) handler.timeout = def.timeout;
15367
+ if (def.statusMessage !== void 0 && def.statusMessage !== null) handler.statusMessage = def.statusMessage;
15368
+ const matcher = def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" ? def.matcher : void 0;
15369
+ const groups = hooks[deepagentsEvent] ??= [];
15370
+ const group = groups.find((candidate) => candidate.matcher === matcher);
15371
+ if (group) group.hooks.push(handler);
15372
+ else groups.push({
15373
+ ...matcher !== void 0 && { matcher },
15374
+ hooks: [handler]
14846
15375
  });
14847
15376
  }
14848
15377
  }
14849
- return entries;
15378
+ return hooks;
15379
+ }
15380
+ /**
15381
+ * Convert the Hooks v2 document back to the canonical hooks record.
15382
+ */
15383
+ function deepagentsToCanonicalHooks(hooks) {
15384
+ const canonical = {};
15385
+ for (const [deepagentsEvent, groups] of Object.entries(hooks)) {
15386
+ const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
15387
+ if (!canonicalEvent || !Array.isArray(groups)) continue;
15388
+ for (const group of groups) {
15389
+ if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
15390
+ for (const handler of group.hooks) {
15391
+ if (!isRecord(handler) || typeof handler.command !== "string") continue;
15392
+ const def = {
15393
+ type: "command",
15394
+ command: handler.command
15395
+ };
15396
+ if (typeof group.matcher === "string" && group.matcher !== "") def.matcher = group.matcher;
15397
+ if (typeof handler.timeout === "number") def.timeout = handler.timeout;
15398
+ if (typeof handler.statusMessage === "string") def.statusMessage = handler.statusMessage;
15399
+ (canonical[canonicalEvent] ??= []).push(def);
15400
+ }
15401
+ }
15402
+ }
15403
+ return canonical;
14850
15404
  }
14851
15405
  /**
14852
- * Convert deepagents flat array format back to canonical hooks record.
15406
+ * Read the pre-v2 flat list. deepagents still loads it until 2026-09-01, so a
15407
+ * `hooks.json` a user has not migrated yet is imported rather than discarded —
15408
+ * but rulesync only ever writes the v2 shape, so regenerating migrates it.
14853
15409
  */
14854
- function deepagentsToCanonicalHooks(hooksEntries) {
15410
+ function deepagentsLegacyToCanonicalHooks(entries) {
14855
15411
  const canonical = {};
14856
- for (const entry of hooksEntries) {
14857
- if (typeof entry !== "object" || entry === null) continue;
14858
- if (!Array.isArray(entry.command) || entry.command.length === 0) continue;
14859
- let command;
14860
- if (entry.command.length === 3 && entry.command[0] === "bash" && entry.command[1] === "-c") command = entry.command[2] ?? "";
14861
- else command = entry.command.join(" ");
14862
- const events = entry.events ?? [];
14863
- for (const deepagentsEvent of events) {
14864
- const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
15412
+ for (const entry of entries) {
15413
+ if (!isRecord(entry)) continue;
15414
+ const argv = entry.command;
15415
+ if (!Array.isArray(argv) || argv.length === 0) continue;
15416
+ const command = argv.length === 3 && argv[0] === "bash" && argv[1] === "-c" ? String(argv[2] ?? "") : argv.join(" ");
15417
+ const events = Array.isArray(entry.events) ? entry.events : [];
15418
+ for (const legacyEvent of events) {
15419
+ const canonicalEvent = typeof legacyEvent === "string" ? DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES[legacyEvent] : void 0;
14865
15420
  if (!canonicalEvent) continue;
14866
- const existing = canonical[canonicalEvent];
14867
- if (existing) existing.push({
15421
+ (canonical[canonicalEvent] ??= []).push({
14868
15422
  type: "command",
14869
15423
  command
14870
15424
  });
14871
- else canonical[canonicalEvent] = [{
14872
- type: "command",
14873
- command
14874
- }];
14875
15425
  }
14876
15426
  }
14877
15427
  return canonical;
@@ -14880,7 +15430,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14880
15430
  constructor(params) {
14881
15431
  super({
14882
15432
  ...params,
14883
- fileContent: params.fileContent ?? JSON.stringify({ hooks: [] }, null, 2)
15433
+ fileContent: params.fileContent ?? JSON.stringify({ hooks: {} }, null, 2)
14884
15434
  });
14885
15435
  }
14886
15436
  isDeletable() {
@@ -14894,7 +15444,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14894
15444
  }
14895
15445
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
14896
15446
  const paths = DeepagentsHooks.getSettablePaths({ global });
14897
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ hooks: [] }, null, 2);
15447
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ hooks: {} }, null, 2);
14898
15448
  return new DeepagentsHooks({
14899
15449
  outputRoot,
14900
15450
  relativeDirPath: paths.relativeDirPath,
@@ -14922,7 +15472,8 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14922
15472
  } catch (error) {
14923
15473
  throw new Error(`Failed to parse deepagents hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
14924
15474
  }
14925
- const hooks = deepagentsToCanonicalHooks(isDeepagentsHooksFile(parsed) ? parsed.hooks : []);
15475
+ const rawHooks = isRecord(parsed) ? parsed.hooks : void 0;
15476
+ const hooks = Array.isArray(rawHooks) ? deepagentsLegacyToCanonicalHooks(rawHooks) : isRecord(rawHooks) ? deepagentsToCanonicalHooks(rawHooks) : {};
14926
15477
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
14927
15478
  hooks,
14928
15479
  overrideKey: "deepagents"
@@ -14939,7 +15490,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14939
15490
  outputRoot,
14940
15491
  relativeDirPath,
14941
15492
  relativeFilePath,
14942
- fileContent: JSON.stringify({ hooks: [] }, null, 2),
15493
+ fileContent: JSON.stringify({ hooks: {} }, null, 2),
14943
15494
  validate: false
14944
15495
  });
14945
15496
  }
@@ -15041,7 +15592,7 @@ var DevinHooks = class DevinHooks extends ToolHooks {
15041
15592
  validate
15042
15593
  });
15043
15594
  }
15044
- toRulesyncHooks() {
15595
+ toRulesyncHooks({ logger } = {}) {
15045
15596
  let parsed;
15046
15597
  try {
15047
15598
  parsed = JSON.parse(this.getFileContent());
@@ -15049,8 +15600,9 @@ var DevinHooks = class DevinHooks extends ToolHooks {
15049
15600
  throw new Error(`Failed to parse Devin hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
15050
15601
  }
15051
15602
  const hooks = toolHooksToCanonical({
15052
- hooks: this.getRelativeFilePath() === "config.json" ? isRecord(parsed) && isRecord(parsed.hooks) ? parsed.hooks : {} : parsed,
15053
- converterConfig: DEVIN_CONVERTER_CONFIG
15603
+ hooks: this.getRelativeFilePath() === "config.json" ? isRecord$1(parsed) && isRecord$1(parsed.hooks) ? parsed.hooks : {} : parsed,
15604
+ converterConfig: DEVIN_CONVERTER_CONFIG,
15605
+ logger
15054
15606
  });
15055
15607
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15056
15608
  hooks,
@@ -15081,7 +15633,13 @@ const FACTORYDROID_CONVERTER_CONFIG = {
15081
15633
  toolToCanonicalEventNames: FACTORYDROID_TO_CANONICAL_EVENT_NAMES,
15082
15634
  projectDirVar: "$FACTORY_PROJECT_DIR",
15083
15635
  prefixDotRelativeCommandsOnly: true,
15084
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15636
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15637
+ groupPassthroughFields: [{
15638
+ canonical: "commandRegex",
15639
+ tool: "commandRegex",
15640
+ valueType: "string",
15641
+ subdividesGroup: true
15642
+ }]
15085
15643
  };
15086
15644
  var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15087
15645
  constructor(params) {
@@ -15138,7 +15696,7 @@ var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15138
15696
  validate
15139
15697
  });
15140
15698
  }
15141
- toRulesyncHooks() {
15699
+ toRulesyncHooks({ logger } = {}) {
15142
15700
  let settings;
15143
15701
  try {
15144
15702
  settings = JSON.parse(this.getFileContent());
@@ -15147,7 +15705,8 @@ var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15147
15705
  }
15148
15706
  const hooks = toolHooksToCanonical({
15149
15707
  hooks: settings.hooks,
15150
- converterConfig: FACTORYDROID_CONVERTER_CONFIG
15708
+ converterConfig: FACTORYDROID_CONVERTER_CONFIG,
15709
+ logger
15151
15710
  });
15152
15711
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15153
15712
  hooks,
@@ -15177,7 +15736,8 @@ const GOOSE_CONVERTER_CONFIG = {
15177
15736
  canonicalToToolEventNames: CANONICAL_TO_GOOSE_EVENT_NAMES,
15178
15737
  toolToCanonicalEventNames: GOOSE_TO_CANONICAL_EVENT_NAMES,
15179
15738
  projectDirVar: "",
15180
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15739
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15740
+ wildcardMatcherMeansAll: true
15181
15741
  };
15182
15742
  /**
15183
15743
  * Represents a Goose lifecycle hooks file.
@@ -15215,13 +15775,14 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15215
15775
  validate
15216
15776
  });
15217
15777
  }
15218
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
15778
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
15219
15779
  const paths = GooseHooks.getSettablePaths({ global });
15220
15780
  const config = rulesyncHooks.getJson();
15221
15781
  const gooseHooks = canonicalToToolHooks({
15222
15782
  config,
15223
15783
  toolOverrideHooks: config.goose?.hooks,
15224
- converterConfig: GOOSE_CONVERTER_CONFIG
15784
+ converterConfig: GOOSE_CONVERTER_CONFIG,
15785
+ logger
15225
15786
  });
15226
15787
  const fileContent = JSON.stringify({ hooks: gooseHooks }, null, 2);
15227
15788
  return new GooseHooks({
@@ -15232,7 +15793,7 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15232
15793
  validate
15233
15794
  });
15234
15795
  }
15235
- toRulesyncHooks() {
15796
+ toRulesyncHooks({ logger } = {}) {
15236
15797
  let parsed;
15237
15798
  try {
15238
15799
  parsed = JSON.parse(this.getFileContent());
@@ -15241,7 +15802,8 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15241
15802
  }
15242
15803
  const hooks = toolHooksToCanonical({
15243
15804
  hooks: parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks) ? Object.fromEntries(Object.entries(parsed.hooks).filter(([eventName]) => Object.hasOwn(GOOSE_TO_CANONICAL_EVENT_NAMES, eventName))) : parsed.hooks,
15244
- converterConfig: GOOSE_CONVERTER_CONFIG
15805
+ converterConfig: GOOSE_CONVERTER_CONFIG,
15806
+ logger
15245
15807
  });
15246
15808
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15247
15809
  hooks,
@@ -15272,6 +15834,11 @@ const GROKCLI_CONVERTER_CONFIG = {
15272
15834
  toolToCanonicalEventNames: GROKCLI_TO_CANONICAL_EVENT_NAMES,
15273
15835
  projectDirVar: "",
15274
15836
  supportedHookTypes: /* @__PURE__ */ new Set(["command", "http"]),
15837
+ recordPassthroughFields: [{
15838
+ canonical: "env",
15839
+ tool: "env",
15840
+ commandOnly: true
15841
+ }],
15275
15842
  noMatcherEvents: /* @__PURE__ */ new Set([
15276
15843
  "sessionStart",
15277
15844
  "sessionEnd",
@@ -15340,7 +15907,7 @@ var GrokcliHooks = class GrokcliHooks extends ToolHooks {
15340
15907
  validate
15341
15908
  });
15342
15909
  }
15343
- toRulesyncHooks() {
15910
+ toRulesyncHooks({ logger } = {}) {
15344
15911
  let parsed;
15345
15912
  try {
15346
15913
  parsed = JSON.parse(this.getFileContent());
@@ -15348,8 +15915,9 @@ var GrokcliHooks = class GrokcliHooks extends ToolHooks {
15348
15915
  throw new Error(`Failed to parse Grok hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
15349
15916
  }
15350
15917
  const hooks = toolHooksToCanonical({
15351
- hooks: isRecord(parsed) && isRecord(parsed.hooks) ? parsed.hooks : {},
15352
- converterConfig: GROKCLI_CONVERTER_CONFIG
15918
+ hooks: isRecord$1(parsed) && isRecord$1(parsed.hooks) ? parsed.hooks : {},
15919
+ converterConfig: GROKCLI_CONVERTER_CONFIG,
15920
+ logger
15353
15921
  });
15354
15922
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15355
15923
  hooks,
@@ -15670,7 +16238,7 @@ var JunieHooks = class JunieHooks extends ToolHooks {
15670
16238
  validate
15671
16239
  });
15672
16240
  }
15673
- toRulesyncHooks() {
16241
+ toRulesyncHooks({ logger } = {}) {
15674
16242
  let settings;
15675
16243
  try {
15676
16244
  settings = JSON.parse(this.getFileContent());
@@ -15679,7 +16247,8 @@ var JunieHooks = class JunieHooks extends ToolHooks {
15679
16247
  }
15680
16248
  const hooks = toolHooksToCanonical({
15681
16249
  hooks: settings.hooks,
15682
- converterConfig: JUNIE_CONVERTER_CONFIG
16250
+ converterConfig: JUNIE_CONVERTER_CONFIG,
16251
+ logger
15683
16252
  });
15684
16253
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15685
16254
  hooks,
@@ -16417,7 +16986,7 @@ function buildKiroIdeEntriesForEvent(trigger, definitions) {
16417
16986
  ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
16418
16987
  action,
16419
16988
  ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
16420
- enabled: true
16989
+ enabled: def.enabled ?? true
16421
16990
  });
16422
16991
  }
16423
16992
  return entries;
@@ -16457,6 +17026,7 @@ function kiroIdeHooksToCanonical(entries) {
16457
17026
  if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
16458
17027
  if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
16459
17028
  if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17029
+ if (entry.enabled === false) def.enabled = false;
16460
17030
  (canonical[eventName] ??= []).push(def);
16461
17031
  }
16462
17032
  return canonical;
@@ -17401,6 +17971,37 @@ function unsupportedEventNames(params) {
17401
17971
  const eventNames = factory.passthroughOverrideEvents ? Object.keys(sharedHooks) : Object.keys(effectiveHooks);
17402
17972
  return [...new Set(eventNames)].filter((e) => !supportedEvents.has(e));
17403
17973
  }
17974
+ /**
17975
+ * A logger whose warnings name the tool being converted, matching the rest of
17976
+ * this processor's warnings: the shared hooks converter says what is wrong but
17977
+ * not which tool's file it is reading or writing, and one run walks them all.
17978
+ *
17979
+ * Every method delegates explicitly rather than through a prototype, so the
17980
+ * real logger keeps owning its state — a wrapper that inherited it would
17981
+ * absorb the writes `configure` and `outputJson` make.
17982
+ */
17983
+ function withToolTargetPrefix({ logger, toolTarget }) {
17984
+ return {
17985
+ configure: (options) => logger.configure(options),
17986
+ get verbose() {
17987
+ return logger.verbose;
17988
+ },
17989
+ get silent() {
17990
+ return logger.silent;
17991
+ },
17992
+ get jsonMode() {
17993
+ return logger.jsonMode;
17994
+ },
17995
+ captureData: (key, value) => logger.captureData(key, value),
17996
+ getJsonData: () => logger.getJsonData(),
17997
+ outputJson: (success, error) => logger.outputJson(success, error),
17998
+ info: (message, ...args) => logger.info(message, ...args),
17999
+ success: (message, ...args) => logger.success(message, ...args),
18000
+ warn: (message, ...args) => logger.warn(`For ${toolTarget}: ${message}`, ...args),
18001
+ error: (message, code, ...args) => logger.error(message, code, ...args),
18002
+ debug: (message, ...args) => logger.debug(message, ...args)
18003
+ };
18004
+ }
17404
18005
  function unsupportedMatcherEventNames({ factory, effectiveHooks }) {
17405
18006
  if (factory.supportsMatcher && !factory.matcherEvents) return [];
17406
18007
  const matcherEvents = factory.matcherEvents ? new Set(factory.matcherEvents) : void 0;
@@ -17623,13 +18224,13 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
17623
18224
  ["deepagents", {
17624
18225
  class: DeepagentsHooks,
17625
18226
  meta: {
17626
- supportsProject: false,
18227
+ supportsProject: true,
17627
18228
  supportsGlobal: true,
17628
18229
  supportsImport: true
17629
18230
  },
17630
18231
  supportedEvents: DEEPAGENTS_HOOK_EVENTS,
17631
18232
  supportedHookTypes: ["command"],
17632
- supportsMatcher: false
18233
+ supportsMatcher: true
17633
18234
  }],
17634
18235
  ["kiro", {
17635
18236
  class: KiroHooks,
@@ -17841,6 +18442,15 @@ var HooksProcessor = class extends FeatureProcessor {
17841
18442
  }
17842
18443
  for (const [hookType, events] of unsupportedTypeToEvents) this.logger.warn(`Skipped ${hookType}-type hook(s) for ${this.toolTarget} (not supported): ${Array.from(events).join(", ")}`);
17843
18444
  }
18445
+ if (this.toolTarget !== "kiro-ide") {
18446
+ const skippedEvents = new Set(unsupportedEventNames({
18447
+ factory,
18448
+ sharedHooks,
18449
+ effectiveHooks
18450
+ }));
18451
+ const eventsWithDisabledHooks = Object.entries(sharedHooks).filter(([event, defs]) => !skippedEvents.has(event) && defs.some((def) => def.enabled === false)).map(([event]) => event);
18452
+ if (eventsWithDisabledHooks.length > 0) this.logger.warn(`Emitting "enabled: false" hook(s) as active for ${this.toolTarget} (only kiro-ide supports the flag): ${eventsWithDisabledHooks.join(", ")}`);
18453
+ }
17844
18454
  const eventsWithUnsupportedMatcher = unsupportedMatcherEventNames({
17845
18455
  factory,
17846
18456
  effectiveHooks
@@ -17851,7 +18461,10 @@ var HooksProcessor = class extends FeatureProcessor {
17851
18461
  rulesyncHooks,
17852
18462
  validate: true,
17853
18463
  global: this.global,
17854
- logger: this.logger
18464
+ logger: withToolTargetPrefix({
18465
+ logger: this.logger,
18466
+ toolTarget: this.toolTarget
18467
+ })
17855
18468
  })];
17856
18469
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
17857
18470
  outputRoot: this.outputRoot,
@@ -17861,7 +18474,12 @@ var HooksProcessor = class extends FeatureProcessor {
17861
18474
  return result;
17862
18475
  }
17863
18476
  async convertToolFilesToRulesyncFiles(toolFiles) {
17864
- return toolFiles.filter((f) => f instanceof ToolHooks).map((h) => h.toRulesyncHooks({ logger: this.logger }));
18477
+ const hooks = toolFiles.filter((f) => f instanceof ToolHooks);
18478
+ const logger = withToolTargetPrefix({
18479
+ logger: this.logger,
18480
+ toolTarget: this.toolTarget
18481
+ });
18482
+ return hooks.map((h) => h.toRulesyncHooks({ logger }));
17865
18483
  }
17866
18484
  static getToolTargets({ global = false, importOnly = false } = {}) {
17867
18485
  if (global) return importOnly ? hooksProcessorToolTargetsGlobalImportable : hooksProcessorToolTargetsGlobal;
@@ -19504,9 +20122,9 @@ function parseAmpSettingsJsonc(fileContent) {
19504
20122
  }
19505
20123
  function filterMcpServers(mcpServers) {
19506
20124
  const filtered = {};
19507
- if (!isRecord(mcpServers)) return filtered;
20125
+ if (!isRecord$1(mcpServers)) return filtered;
19508
20126
  for (const [name, config] of Object.entries(mcpServers)) {
19509
- if (isPrototypePollutionKey(name) || !isRecord(config)) continue;
20127
+ if (isPrototypePollutionKey(name) || !isRecord$1(config)) continue;
19510
20128
  const filteredConfig = {};
19511
20129
  for (const [key, value] of Object.entries(config)) {
19512
20130
  if (isPrototypePollutionKey(key)) continue;
@@ -19621,7 +20239,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
19621
20239
  success: true,
19622
20240
  error: null
19623
20241
  };
19624
- if (!isRecord(mcpServers)) return {
20242
+ if (!isRecord$1(mcpServers)) return {
19625
20243
  success: false,
19626
20244
  error: /* @__PURE__ */ new Error(`${AMP_MCP_SERVERS_KEY} must be a JSON object`)
19627
20245
  };
@@ -19630,7 +20248,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
19630
20248
  success: false,
19631
20249
  error: /* @__PURE__ */ new Error(`Server name "${serverName}" is a prototype pollution key and is not allowed`)
19632
20250
  };
19633
- if (!isRecord(serverConfig)) return {
20251
+ if (!isRecord$1(serverConfig)) return {
19634
20252
  success: false,
19635
20253
  error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
19636
20254
  };
@@ -20230,13 +20848,13 @@ function normalizeCodexMcpServerName(name) {
20230
20848
  function convertFromCodexFormat(codexMcp) {
20231
20849
  const result = {};
20232
20850
  for (const [name, config] of Object.entries(codexMcp)) {
20233
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
20851
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
20234
20852
  const converted = {};
20235
20853
  for (const [key, value] of Object.entries(config)) {
20236
20854
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20237
20855
  if (key === "enabled") {
20238
20856
  if (value === false) converted["disabled"] = true;
20239
- } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthFromCodex(value);
20857
+ } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthFromCodex(value);
20240
20858
  else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
20241
20859
  const mappedKey = CODEX_TO_RULESYNC_FIELD_MAP[key];
20242
20860
  if (mappedKey) if (isValidRenamedArray(key, value)) converted[mappedKey] = value;
@@ -20255,7 +20873,7 @@ function convertToCodexFormat(mcpServers) {
20255
20873
  const result = {};
20256
20874
  const originalNames = /* @__PURE__ */ new Map();
20257
20875
  for (const [name, config] of Object.entries(mcpServers)) {
20258
- if (!isRecord(config)) continue;
20876
+ if (!isRecord$1(config)) continue;
20259
20877
  const { codexName, usedFallback } = normalizeCodexMcpServerName(name);
20260
20878
  if (usedFallback) warnWithFallback(void 0, `MCP server "${name}" cannot be represented as a Codex MCP server name (ASCII [a-zA-Z0-9_-] only), so the stable fallback name "${codexName}" was used. Rename the server in .rulesync/mcp.jsonc to choose a readable Codex name.`);
20261
20879
  const converted = {};
@@ -20263,7 +20881,7 @@ function convertToCodexFormat(mcpServers) {
20263
20881
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20264
20882
  if (key === "disabled") {
20265
20883
  if (value === true) converted["enabled"] = false;
20266
- } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthToCodex(value);
20884
+ } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthToCodex(value);
20267
20885
  else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
20268
20886
  const mappedKey = RULESYNC_TO_CODEX_FIELD_MAP[key];
20269
20887
  if (mappedKey) if (isValidRenamedArray(key, value)) converted[mappedKey] = value;
@@ -20339,21 +20957,21 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
20339
20957
  const strippedMcpServers = rulesyncMcp.getMcpServers();
20340
20958
  const rawMcpServers = rulesyncMcp.getJson().mcpServers;
20341
20959
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
20342
- const rawServer = isRecord(rawMcpServers) ? rawMcpServers[serverName] : void 0;
20960
+ const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
20343
20961
  return [serverName, {
20344
20962
  ...serverConfig,
20345
- ...isRecord(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
20346
- ...isRecord(rawServer) && typeof rawServer.experimental_environment === "string" ? { experimentalEnvironment: rawServer.experimental_environment } : {},
20347
- ...isRecord(rawServer) && typeof rawServer.experimentalEnvironment === "string" ? { experimentalEnvironment: rawServer.experimentalEnvironment } : {}
20963
+ ...isRecord$1(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
20964
+ ...isRecord$1(rawServer) && typeof rawServer.experimental_environment === "string" ? { experimentalEnvironment: rawServer.experimental_environment } : {},
20965
+ ...isRecord$1(rawServer) && typeof rawServer.experimentalEnvironment === "string" ? { experimentalEnvironment: rawServer.experimentalEnvironment } : {}
20348
20966
  }];
20349
20967
  })));
20350
20968
  const filteredMcpServers = this.removeEmptyEntries(converted);
20351
20969
  for (const name of Object.keys(converted)) if (!Object.hasOwn(filteredMcpServers, name)) warnWithFallback(void 0, `MCP server "${name}" had no non-empty configuration and was dropped from the codex CLI config`);
20352
- const existingMcpServers = isRecord(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
20970
+ const existingMcpServers = isRecord$1(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
20353
20971
  const mergedMcpServers = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
20354
- const existingServer = isRecord(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
20972
+ const existingServer = isRecord$1(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
20355
20973
  const serverRecord = serverConfig;
20356
- if (existingServer && isRecord(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
20974
+ if (existingServer && isRecord$1(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
20357
20975
  ...serverRecord,
20358
20976
  tools: existingServer["tools"]
20359
20977
  }];
@@ -21222,6 +21840,27 @@ function resolveGooseType(config, url) {
21222
21840
  return canonicalTransport(config) === "builtin" ? "builtin" : "stdio";
21223
21841
  }
21224
21842
  /**
21843
+ * The Goose extension types that carry an MCP server. Goose also documents
21844
+ * `builtin`, `platform`, `frontend` and `inline_python` extensions, which have
21845
+ * no canonical MCP counterpart: they name capabilities Goose provides itself
21846
+ * rather than a server rulesync could describe.
21847
+ */
21848
+ const GOOSE_MCP_EXTENSION_TYPES = /* @__PURE__ */ new Set([
21849
+ "stdio",
21850
+ "streamable_http",
21851
+ "sse"
21852
+ ]);
21853
+ /**
21854
+ * Resolves the Goose extension type of an existing `extensions:` entry the way
21855
+ * Goose itself reads it: the declared `type`, or the shape of the entry when
21856
+ * the key is absent.
21857
+ */
21858
+ function existingExtensionType(ext) {
21859
+ if (typeof ext.type === "string") return ext.type;
21860
+ if (typeof ext.cmd === "string") return "stdio";
21861
+ if (typeof ext.uri === "string") return "streamable_http";
21862
+ }
21863
+ /**
21225
21864
  * Resolves the canonical timeout for a server (`timeout` or `networkTimeout`).
21226
21865
  */
21227
21866
  function resolveGooseTimeout(config) {
@@ -21247,15 +21886,20 @@ function applyGooseStdioFields(ext, config) {
21247
21886
  /**
21248
21887
  * Converts a single rulesync canonical MCP server into a Goose `extensions:` entry.
21249
21888
  */
21250
- function convertServerToGooseExtension(name, config) {
21889
+ function convertServerToGooseExtension(name, config, logger) {
21251
21890
  const url = resolveGooseUrl(config);
21252
21891
  const gooseType = resolveGooseType(config, url);
21253
21892
  const ext = {
21254
21893
  name,
21255
21894
  type: gooseType
21256
21895
  };
21257
- if (gooseType === "stdio") applyGooseStdioFields(ext, config);
21258
- else if (gooseType === "sse" || gooseType === "streamable_http") {
21896
+ if (gooseType === "stdio") {
21897
+ applyGooseStdioFields(ext, config);
21898
+ if (typeof ext.cmd !== "string" || ext.cmd === "") {
21899
+ warnWithFallback(logger, `Goose extension "${name}" has no command to run; skipping it rather than writing a stdio extension Goose cannot start to ~/.config/goose/config.yaml.`);
21900
+ return;
21901
+ }
21902
+ } else if (gooseType === "sse" || gooseType === "streamable_http") {
21259
21903
  if (url !== void 0) ext.uri = url;
21260
21904
  if (isPlainObject$1(config.headers)) ext.headers = omitPrototypePollutionKeys(config.headers);
21261
21905
  }
@@ -21270,13 +21914,41 @@ function convertServerToGooseExtension(name, config) {
21270
21914
  * Goose uses a non-standard schema: `name`, `type` (`stdio` | `streamable_http`
21271
21915
  * | `sse` | `builtin`), `cmd`/`args`/`envs` for stdio, `uri`/`headers` for
21272
21916
  * remote, plus `enabled` and `timeout`.
21273
- */
21274
- function convertToGooseFormat(mcpServers) {
21275
- const extensions = {};
21917
+ *
21918
+ * `extensions:` is co-owned: alongside the MCP servers rulesync manages it also
21919
+ * holds Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions
21920
+ * (`developer`, `memory`, ...), which have no canonical MCP representation.
21921
+ * Those entries are carried over from `existingExtensions` untouched — removing
21922
+ * `developer` alone costs the agent its shell and text-editor tools. Only an
21923
+ * entry rulesync can positively identify as an MCP server is rulesync's to
21924
+ * replace, so a server deleted from `.rulesync/.mcp.json` is retracted (with a
21925
+ * warning naming it) while an entry of an unrecognized shape or a future
21926
+ * extension type is left alone rather than assumed to be ours.
21927
+ */
21928
+ function convertToGooseFormat({ mcpServers, existingExtensions, logger }) {
21929
+ const generated = {};
21276
21930
  for (const [name, config] of Object.entries(mcpServers)) {
21277
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
21278
- extensions[name] = convertServerToGooseExtension(name, config);
21931
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21932
+ const ext = convertServerToGooseExtension(name, config, logger);
21933
+ if (ext !== void 0) generated[name] = ext;
21279
21934
  }
21935
+ const extensions = {};
21936
+ const retracted = [];
21937
+ for (const [name, ext] of Object.entries(existingExtensions)) {
21938
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
21939
+ const type = isRecord$1(ext) ? existingExtensionType(ext) : void 0;
21940
+ if (type !== void 0 && GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21941
+ if (!Object.hasOwn(generated, name)) retracted.push(name);
21942
+ continue;
21943
+ }
21944
+ if (!Object.hasOwn(generated, name)) {
21945
+ extensions[name] = ext;
21946
+ continue;
21947
+ }
21948
+ if (generated[name]?.type !== type) warnWithFallback(logger, `Goose extension "${name}" already exists in config.yaml as a non-MCP extension; the MCP server of the same name replaces it.`);
21949
+ }
21950
+ if (retracted.length > 0) warnWithFallback(logger, `Removing MCP extension(s) ${retracted.map((name) => `"${name}"`).join(", ")} from ~/.config/goose/config.yaml: they are not in the generated rulesync MCP config. Import them first if they were added with \`goose configure\`.`);
21951
+ Object.assign(extensions, generated);
21280
21952
  return extensions;
21281
21953
  }
21282
21954
  /**
@@ -21287,16 +21959,27 @@ function convertToGooseFormat(mcpServers) {
21287
21959
  * so both `url` and the Claude-specific `httpUrl` alias come back as `url`; and
21288
21960
  * the `streamable_http` type maps back to canonical `http`. These are the
21289
21961
  * canonical/preferred forms, so re-generating produces an equivalent config.
21962
+ *
21963
+ * Non-MCP extension types (`builtin`, `platform`, `frontend`, `inline_python`)
21964
+ * are skipped: they describe capabilities Goose provides itself, and importing
21965
+ * one would strip the type that makes it work — a `builtin` entry came back as
21966
+ * a `stdio` extension with no `cmd` that Goose cannot start. They stay in `config.yaml`,
21967
+ * which generation preserves.
21290
21968
  */
21291
21969
  function convertFromGooseFormat(extensions) {
21292
21970
  const result = {};
21971
+ const skipped = [];
21293
21972
  for (const [name, ext] of Object.entries(extensions)) {
21294
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(ext)) continue;
21973
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(ext)) continue;
21974
+ const type = existingExtensionType(ext);
21975
+ if (type === void 0 || !GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21976
+ skipped.push(name);
21977
+ continue;
21978
+ }
21295
21979
  const server = {};
21296
- const type = typeof ext.type === "string" ? ext.type : void 0;
21297
21980
  if (type === "sse") server.type = "sse";
21298
21981
  else if (type === "streamable_http") server.type = "http";
21299
- else if (type === "stdio") server.type = "stdio";
21982
+ else server.type = "stdio";
21300
21983
  if (typeof ext.cmd === "string") server.command = ext.cmd;
21301
21984
  if (isStringArray$1(ext.args)) server.args = ext.args;
21302
21985
  if (isPlainObject$1(ext.envs)) server.env = omitPrototypePollutionKeys(ext.envs);
@@ -21306,6 +21989,7 @@ function convertFromGooseFormat(extensions) {
21306
21989
  if (typeof ext.timeout === "number") server.timeout = ext.timeout;
21307
21990
  result[name] = server;
21308
21991
  }
21992
+ if (skipped.length > 0) warnWithFallback(void 0, `Skipping ${skipped.length} non-MCP Goose extension(s) (${skipped.map((name) => `"${name}"`).join(", ")}): they describe capabilities Goose provides itself and have no rulesync representation.`);
21309
21993
  return result;
21310
21994
  }
21311
21995
  /**
@@ -21340,7 +22024,7 @@ function buildGoosePluginStdioServer(config) {
21340
22024
  function convertToGoosePluginMcpServers(mcpServers, logger) {
21341
22025
  const result = {};
21342
22026
  for (const [name, config] of Object.entries(mcpServers)) {
21343
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22027
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21344
22028
  const gooseType = resolveGooseType(config, resolveGooseUrl(config));
21345
22029
  if (gooseType !== "stdio") {
21346
22030
  warnWithFallback(logger, `Goose open-plugin MCP manifest (${GOOSE_PLUGIN_MCP_RELATIVE_PATH}) is stdio-only; skipping "${name}" (${gooseType}). Sync it with --global to ~/.config/goose/config.yaml instead.`);
@@ -21381,7 +22065,7 @@ var GooseMcp = class GooseMcp extends ToolMcp {
21381
22065
  parsePluginManifest(fileContent) {
21382
22066
  try {
21383
22067
  const parsed = JSON.parse(fileContent);
21384
- return isRecord(parsed) ? parsed : {};
22068
+ return isRecord$1(parsed) ? parsed : {};
21385
22069
  } catch (error) {
21386
22070
  throw new Error(`Failed to parse Goose MCP manifest at ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(error)}`, { cause: error });
21387
22071
  }
@@ -21427,25 +22111,35 @@ var GooseMcp = class GooseMcp extends ToolMcp {
21427
22111
  global
21428
22112
  });
21429
22113
  }
21430
- const merged = {
21431
- ...parseGooseConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "", paths.relativeDirPath, paths.relativeFilePath),
21432
- extensions: convertToGooseFormat(rulesyncMcp.getMcpServers())
21433
- };
22114
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22115
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
22116
+ const config = parseGooseConfig(existingContent, paths.relativeDirPath, paths.relativeFilePath);
22117
+ const existingExtensions = isRecord$1(config.extensions) ? config.extensions : {};
21434
22118
  return new GooseMcp({
21435
22119
  outputRoot,
21436
22120
  relativeDirPath: paths.relativeDirPath,
21437
22121
  relativeFilePath: paths.relativeFilePath,
21438
- fileContent: (0, js_yaml.dump)(merged),
22122
+ fileContent: applySharedConfigPatch({
22123
+ fileKey: sharedConfigFileKey(paths),
22124
+ feature: "mcp",
22125
+ existingContent,
22126
+ patch: { extensions: convertToGooseFormat({
22127
+ mcpServers: rulesyncMcp.getMcpServers(),
22128
+ existingExtensions,
22129
+ logger
22130
+ }) },
22131
+ filePath
22132
+ }),
21439
22133
  validate,
21440
22134
  global
21441
22135
  });
21442
22136
  }
21443
22137
  toRulesyncMcp() {
21444
22138
  if (!this.global) {
21445
- const mcpServers = isRecord(this.config.mcpServers) ? this.config.mcpServers : {};
22139
+ const mcpServers = isRecord$1(this.config.mcpServers) ? this.config.mcpServers : {};
21446
22140
  return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
21447
22141
  }
21448
- const mcpServers = convertFromGooseFormat(isRecord(this.config.extensions) ? this.config.extensions : {});
22142
+ const mcpServers = convertFromGooseFormat(isRecord$1(this.config.extensions) ? this.config.extensions : {});
21449
22143
  return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
21450
22144
  }
21451
22145
  validate() {
@@ -21481,7 +22175,7 @@ function convertToGrokFormat(mcpServers) {
21481
22175
  const result = {};
21482
22176
  for (const [name, config] of Object.entries(mcpServers)) {
21483
22177
  if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
21484
- if (!isRecord(config)) continue;
22178
+ if (!isRecord$1(config)) continue;
21485
22179
  const converted = {};
21486
22180
  for (const [key, value] of Object.entries(config)) {
21487
22181
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
@@ -21496,7 +22190,7 @@ function convertToGrokFormat(mcpServers) {
21496
22190
  function convertFromGrokFormat(grokMcp) {
21497
22191
  const result = {};
21498
22192
  for (const [name, config] of Object.entries(grokMcp)) {
21499
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22193
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21500
22194
  const converted = {};
21501
22195
  for (const [key, value] of Object.entries(config)) {
21502
22196
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
@@ -21634,7 +22328,7 @@ function resolveHermesTimeout(config) {
21634
22328
  * import alike. See the Hermes mcp-config-reference.
21635
22329
  */
21636
22330
  function copyHermesOauth(source) {
21637
- if (!isRecord(source)) return;
22331
+ if (!isRecord$1(source)) return;
21638
22332
  const oauth = {};
21639
22333
  for (const key of [
21640
22334
  "redirect_uri",
@@ -21768,13 +22462,13 @@ function convertServerToHermes(config) {
21768
22462
  function convertToHermesFormat(mcpServers) {
21769
22463
  const result = {};
21770
22464
  for (const [name, config] of Object.entries(mcpServers)) {
21771
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22465
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21772
22466
  result[name] = convertServerToHermes(config);
21773
22467
  }
21774
22468
  return result;
21775
22469
  }
21776
22470
  function mergeHermesMcpServers(config, mcpServers) {
21777
- const existingMcpServers = isRecord(config.mcp_servers) ? config.mcp_servers : {};
22471
+ const existingMcpServers = isRecord$1(config.mcp_servers) ? config.mcp_servers : {};
21778
22472
  return {
21779
22473
  ...config,
21780
22474
  mcp_servers: {
@@ -21793,7 +22487,7 @@ function convertFromHermesFormat(mcpServers) {
21793
22487
  const result = {};
21794
22488
  const hermesOverrides = {};
21795
22489
  for (const [name, config] of Object.entries(mcpServers)) {
21796
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22490
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21797
22491
  const server = {};
21798
22492
  if (typeof config.command === "string") server.command = config.command;
21799
22493
  if (isStringArray$1(config.args)) server.args = config.args;
@@ -21802,7 +22496,7 @@ function convertFromHermesFormat(mcpServers) {
21802
22496
  if (isPlainObject$1(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
21803
22497
  if (config.enabled === false) server.disabled = true;
21804
22498
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
21805
- if (isRecord(config.tools)) applyHermesToolsBlock(config.tools, server);
22499
+ if (isRecord$1(config.tools)) applyHermesToolsBlock(config.tools, server);
21806
22500
  result[name] = server;
21807
22501
  const hermesServer = { ...server };
21808
22502
  if (copyHermesAdvancedFields(config, hermesServer)) hermesOverrides[name] = hermesServer;
@@ -21841,7 +22535,7 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
21841
22535
  const merged = mergeHermesMcpServers(parseSharedConfig({
21842
22536
  format: "yaml",
21843
22537
  fileContent
21844
- }), isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {});
22538
+ }), isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {});
21845
22539
  this.config = merged;
21846
22540
  super.setFileContent(applySharedConfigPatch({
21847
22541
  fileKey: getHermesagentConfigSharedFileKey({ global: this.global }),
@@ -21905,7 +22599,7 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
21905
22599
  });
21906
22600
  }
21907
22601
  toRulesyncMcp() {
21908
- const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {});
22602
+ const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {});
21909
22603
  return this.toRulesyncMcpDefault({
21910
22604
  outputRoot: getHermesagentRulesyncOutputRoot({
21911
22605
  nativeOutputRoot: this.outputRoot,
@@ -22217,7 +22911,7 @@ function convertServerToKiloFormat(serverName, serverConfig, existingEntry, logg
22217
22911
  */
22218
22912
  function readExistingKiloMcpEntries(fileContent) {
22219
22913
  const mcp = (0, jsonc_parser.parse)(fileContent || "{}")?.mcp;
22220
- if (!isRecord(mcp)) return {};
22914
+ if (!isRecord$1(mcp)) return {};
22221
22915
  const entries = {};
22222
22916
  for (const [serverName, entry] of Object.entries(mcp)) {
22223
22917
  const result = KiloMcpServerSchema.safeParse(entry);
@@ -22521,7 +23215,7 @@ async function readKimiCodeConfig({ outputRoot }) {
22521
23215
  return {
22522
23216
  parsed: true,
22523
23217
  content,
22524
- mcp: isRecord(mcp) ? mcp : {}
23218
+ mcp: isRecord$1(mcp) ? mcp : {}
22525
23219
  };
22526
23220
  } catch {
22527
23221
  return {
@@ -22675,7 +23369,7 @@ var KimiCodeMcp = class KimiCodeMcp extends ToolMcp {
22675
23369
  static async getAuxiliaryFiles({ outputRoot = process.cwd(), global = false, rulesyncMcp, logger }) {
22676
23370
  if (!global) return [];
22677
23371
  const block = rulesyncMcp.getJson()["kimi-code"];
22678
- if (!isRecord(block)) return [];
23372
+ if (!isRecord$1(block)) return [];
22679
23373
  const startupTimeoutMs = typeof block.startupTimeoutMs === "number" ? block.startupTimeoutMs : void 0;
22680
23374
  const toolTimeoutMs = typeof block.toolTimeoutMs === "number" ? block.toolTimeoutMs : void 0;
22681
23375
  if (startupTimeoutMs === void 0 && toolTimeoutMs === void 0) return [];
@@ -22728,6 +23422,68 @@ var KimiCodeMcp = class KimiCodeMcp extends ToolMcp {
22728
23422
  };
22729
23423
  //#endregion
22730
23424
  //#region src/features/mcp/kiro-mcp.ts
23425
+ /**
23426
+ * Union of two optional string lists, preserving order and dropping duplicates.
23427
+ * Returns `undefined` only when neither side was authored at all, so the caller
23428
+ * omits the key entirely rather than writing an empty array — but an explicitly
23429
+ * authored `[]` is kept, which keeps import → generate idempotent.
23430
+ */
23431
+ function mergeToolLists(...lists) {
23432
+ if (lists.every((list) => list === void 0)) return void 0;
23433
+ const merged = [];
23434
+ for (const list of lists) for (const tool of list ?? []) if (!merged.includes(tool)) merged.push(tool);
23435
+ return merged;
23436
+ }
23437
+ /**
23438
+ * Translate rulesync's Kiro-only authoring keys onto the field names Kiro
23439
+ * actually reads in `mcp.json`.
23440
+ *
23441
+ * - `kiroAutoApprove` → `autoApprove` (tools run without a confirmation prompt)
23442
+ * - `kiroAutoBlock` → `disabledTools` (tools hidden from the agent)
23443
+ *
23444
+ * `disabledTools` is the only block list Kiro reads, and it is also a canonical
23445
+ * rulesync field, so `kiroAutoBlock` is a redundant spelling of it. Prefer the
23446
+ * canonical field; see the note on `kiroAutoBlock` in `src/types/mcp.ts`.
23447
+ *
23448
+ * Both native names are documented per-server fields, so a config that already
23449
+ * spells them natively keeps working: the two lists are merged rather than
23450
+ * one overwriting the other.
23451
+ * @see https://kiro.dev/docs/mcp/configuration/
23452
+ */
23453
+ function toKiroMcpServers(servers) {
23454
+ return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
23455
+ const { kiroAutoApprove, kiroAutoBlock, disabledTools, ...rest } = server;
23456
+ const autoApprove = mergeToolLists(isStringArray$1(rest.autoApprove) ? rest.autoApprove : void 0, kiroAutoApprove);
23457
+ const disabled = mergeToolLists(disabledTools, kiroAutoBlock);
23458
+ return [name, {
23459
+ ...rest,
23460
+ ...autoApprove !== void 0 && { autoApprove },
23461
+ ...disabled !== void 0 && { disabledTools: disabled }
23462
+ }];
23463
+ }));
23464
+ }
23465
+ /**
23466
+ * Import direction of {@link toKiroMcpServers}: Kiro's `autoApprove` becomes the
23467
+ * rulesync-only `kiroAutoApprove` so a regenerate reproduces it. `disabledTools`
23468
+ * is left alone — it is already a canonical rulesync key with the same meaning,
23469
+ * so `kiroAutoBlock` deliberately has no import counterpart.
23470
+ *
23471
+ * Only a genuine string array is renamed. `kiroAutoApprove` is typed as one, so
23472
+ * moving a hand-written `"autoApprove": "all"` there would produce a
23473
+ * `.rulesync/mcp.jsonc` the next generate refuses to parse; such a value stays
23474
+ * under its original key and passes through untouched instead.
23475
+ */
23476
+ function fromKiroMcpServers(servers) {
23477
+ return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
23478
+ if (server === null || typeof server !== "object" || Array.isArray(server)) return [name, server];
23479
+ const { autoApprove, ...rest } = server;
23480
+ if (!isStringArray$1(autoApprove)) return [name, server];
23481
+ return [name, {
23482
+ ...rest,
23483
+ kiroAutoApprove: autoApprove
23484
+ }];
23485
+ }));
23486
+ }
22731
23487
  var KiroMcp = class KiroMcp extends ToolMcp {
22732
23488
  json;
22733
23489
  constructor(params) {
@@ -22756,7 +23512,7 @@ var KiroMcp = class KiroMcp extends ToolMcp {
22756
23512
  }
22757
23513
  static fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true }) {
22758
23514
  const paths = this.getSettablePaths();
22759
- const fileContent = JSON.stringify({ mcpServers: rulesyncMcp.getMcpServers() }, null, 2);
23515
+ const fileContent = JSON.stringify({ mcpServers: toKiroMcpServers(rulesyncMcp.getMcpServers()) }, null, 2);
22760
23516
  return new KiroMcp({
22761
23517
  outputRoot,
22762
23518
  relativeDirPath: paths.relativeDirPath,
@@ -22766,7 +23522,9 @@ var KiroMcp = class KiroMcp extends ToolMcp {
22766
23522
  });
22767
23523
  }
22768
23524
  toRulesyncMcp() {
22769
- return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: this.json.mcpServers ?? {} }, null, 2) });
23525
+ const mcpServers = this.json.mcpServers;
23526
+ const translated = isMcpServers(mcpServers) ? fromKiroMcpServers(mcpServers) : {};
23527
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: translated }, null, 2) });
22770
23528
  }
22771
23529
  validate() {
22772
23530
  return {
@@ -23652,7 +24410,7 @@ async function readRovodevConfigYaml({ outputRoot }) {
23652
24410
  });
23653
24411
  }
23654
24412
  function disabledNamesOf(config) {
23655
- const mcpBlock = config && isRecord(config.mcp) ? config.mcp : {};
24413
+ const mcpBlock = config && isRecord$1(config.mcp) ? config.mcp : {};
23656
24414
  return isStringArray$1(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
23657
24415
  }
23658
24416
  /**
@@ -23771,7 +24529,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
23771
24529
  const managedNames = Object.keys(servers).filter((name) => toRovodevServer(name, servers[name]) !== null);
23772
24530
  const disabledNames = managedNames.filter((name) => {
23773
24531
  const server = servers[name];
23774
- return isRecord(server) && server.disabled === true;
24532
+ return isRecord$1(server) && server.disabled === true;
23775
24533
  });
23776
24534
  const existingContent = await readFileContentOrNull((0, node_path.join)(outputRoot, ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)) ?? "";
23777
24535
  let existingParsed;
@@ -23785,7 +24543,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
23785
24543
  logger?.warn(`Skipping the Rovo Dev mcp.disabledMcpServers update: ${formatError(error)}`);
23786
24544
  return [];
23787
24545
  }
23788
- const existingMcp = isRecord(existingParsed.mcp) ? { ...existingParsed.mcp } : {};
24546
+ const existingMcp = isRecord$1(existingParsed.mcp) ? { ...existingParsed.mcp } : {};
23789
24547
  const existingDisabled = isStringArray$1(existingMcp.disabledMcpServers) ? existingMcp.disabledMcpServers : [];
23790
24548
  const managedNameSet = new Set(managedNames);
23791
24549
  const reEnabled = existingDisabled.filter((name) => managedNameSet.has(name) && !disabledNames.includes(name));
@@ -23977,7 +24735,7 @@ function deriveTransportAllowlist(servers) {
23977
24735
  http: false
23978
24736
  };
23979
24737
  for (const server of Object.values(servers)) {
23980
- if (!isRecord(server)) continue;
24738
+ if (!isRecord$1(server)) continue;
23981
24739
  const transport = transportOf(server);
23982
24740
  if (transport) allowlist[transport] = true;
23983
24741
  }
@@ -27612,13 +28370,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
27612
28370
  WebFetch: "webfetch",
27613
28371
  Mcp: "mcp"
27614
28372
  };
27615
- const MCP_CANONICAL_PREFIX$1 = "mcp__";
28373
+ const MCP_CANONICAL_PREFIX$2 = "mcp__";
27616
28374
  /**
27617
28375
  * Returns true if the canonical category is the per-tool MCP form
27618
28376
  * `mcp__<server>__<tool>`.
27619
28377
  */
27620
28378
  function isMcpScopedCategory(canonical) {
27621
- return canonical.startsWith(MCP_CANONICAL_PREFIX$1) && canonical.length > 5;
28379
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$2) && canonical.length > 5;
27622
28380
  }
27623
28381
  function toCursorType(canonical) {
27624
28382
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -27652,7 +28410,7 @@ function toCanonicalCategory$1(cursorType, pattern) {
27652
28410
  if (match) {
27653
28411
  const server = match[1] ?? "*";
27654
28412
  const tool = match[2] ?? "*";
27655
- return `${MCP_CANONICAL_PREFIX$1}${server}__${tool}`;
28413
+ return `${MCP_CANONICAL_PREFIX$2}${server}__${tool}`;
27656
28414
  }
27657
28415
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
27658
28416
  }
@@ -27881,7 +28639,7 @@ function convertCursorToRulesyncPermissions(params) {
27881
28639
  const { type, pattern } = parseCursorPermissionEntry(entry);
27882
28640
  const canonical = toCanonicalCategory$1(type, pattern);
27883
28641
  if (!permission[canonical]) permission[canonical] = {};
27884
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$1) ? "*" : pattern;
28642
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$2) ? "*" : pattern;
27885
28643
  permission[canonical][canonicalPattern] = action;
27886
28644
  }
27887
28645
  };
@@ -28016,14 +28774,14 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
28016
28774
  let settings;
28017
28775
  try {
28018
28776
  const parsed = JSON.parse(existingContent);
28019
- settings = isRecord(parsed) ? parsed : {};
28777
+ settings = isRecord$1(parsed) ? parsed : {};
28020
28778
  } catch (error) {
28021
28779
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
28022
28780
  }
28023
28781
  const config = rulesyncPermissions.getJson();
28024
28782
  const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
28025
28783
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
28026
- const existingPermissions = isRecord(settings.permissions) ? settings.permissions : {};
28784
+ const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
28027
28785
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
28028
28786
  const mergedAllow = (0, es_toolkit.uniq)([...preserve(existingPermissions.allow), ...allow].toSorted());
28029
28787
  const mergedAsk = (0, es_toolkit.uniq)([...preserve(existingPermissions.ask), ...ask].toSorted());
@@ -28053,11 +28811,11 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
28053
28811
  let settings;
28054
28812
  try {
28055
28813
  const parsed = JSON.parse(this.getFileContent());
28056
- settings = isRecord(parsed) ? parsed : {};
28814
+ settings = isRecord$1(parsed) ? parsed : {};
28057
28815
  } catch (error) {
28058
28816
  throw new Error(`Failed to parse Devin permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28059
28817
  }
28060
- const permissions = isRecord(settings.permissions) ? settings.permissions : {};
28818
+ const permissions = isRecord$1(settings.permissions) ? settings.permissions : {};
28061
28819
  const config = convertDevinToRulesyncPermissions({
28062
28820
  allow: Array.isArray(permissions.allow) ? permissions.allow : [],
28063
28821
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
@@ -28144,7 +28902,8 @@ const FACTORYDROID_OVERRIDE_KEYS = [
28144
28902
  "interactionMode",
28145
28903
  "extraKnownMarketplaces",
28146
28904
  "enabledPlugins",
28147
- "hooksDisabled"
28905
+ "hooksDisabled",
28906
+ "disabledSkills"
28148
28907
  ];
28149
28908
  /**
28150
28909
  * Permissions adapter for Factory Droid.
@@ -28405,7 +29164,7 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
28405
29164
  } catch (error) {
28406
29165
  throw new Error(`Failed to parse existing Goose permission.yaml at ${filePath}: ${formatError(error)}`, { cause: error });
28407
29166
  }
28408
- const config = isRecord(parsed) ? { ...parsed } : {};
29167
+ const config = isRecord$1(parsed) ? { ...parsed } : {};
28409
29168
  const userPermission = convertRulesyncToGoosePermissionConfig({
28410
29169
  config: rulesyncPermissions.getJson(),
28411
29170
  logger
@@ -28428,8 +29187,8 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
28428
29187
  } catch (error) {
28429
29188
  throw new Error(`Failed to parse Goose permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28430
29189
  }
28431
- const config = isRecord(parsed) ? parsed : {};
28432
- const rulesyncConfig = convertGoosePermissionConfigToRulesync(isRecord(config[GOOSE_USER_KEY]) ? config[GOOSE_USER_KEY] : {});
29190
+ const config = isRecord$1(parsed) ? parsed : {};
29191
+ const rulesyncConfig = convertGoosePermissionConfigToRulesync(isRecord$1(config[GOOSE_USER_KEY]) ? config[GOOSE_USER_KEY] : {});
28433
29192
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
28434
29193
  }
28435
29194
  validate() {
@@ -28503,7 +29262,7 @@ const GROKCLI_UI_KEY = "ui";
28503
29262
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
28504
29263
  const GROKCLI_PERMISSION_KEY = "permission";
28505
29264
  const CATCH_ALL_PATTERN$2 = "*";
28506
- const MCP_CANONICAL_PREFIX = "mcp__";
29265
+ const MCP_CANONICAL_PREFIX$1 = "mcp__";
28507
29266
  const CATEGORY_TO_GROK_TOOL = {
28508
29267
  bash: "Bash",
28509
29268
  read: "Read",
@@ -28531,7 +29290,7 @@ const GROK_MCP_TOOL = "MCPTool";
28531
29290
  * concrete pattern emits `Tool(pattern)`.
28532
29291
  */
28533
29292
  function buildGrokEntry(category, pattern) {
28534
- if (category.startsWith(MCP_CANONICAL_PREFIX)) {
29293
+ if (category.startsWith(MCP_CANONICAL_PREFIX$1)) {
28535
29294
  const remainder = category.slice(5);
28536
29295
  return remainder.length > 0 ? `${GROK_MCP_TOOL}(${remainder})` : GROK_MCP_TOOL;
28537
29296
  }
@@ -28558,7 +29317,7 @@ function parseGrokEntry(entry) {
28558
29317
  inner = trimmed.slice(parenIndex + 1, -1).trim();
28559
29318
  }
28560
29319
  if (tool === GROK_MCP_TOOL) return inner.length > 0 ? {
28561
- category: `${MCP_CANONICAL_PREFIX}${inner}`,
29320
+ category: `${MCP_CANONICAL_PREFIX$1}${inner}`,
28562
29321
  pattern: CATCH_ALL_PATTERN$2
28563
29322
  } : {
28564
29323
  category: "mcp",
@@ -28656,7 +29415,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28656
29415
  throw new Error(`Failed to parse existing Grok config.toml at ${filePath}: ${formatError(error)}`, { cause: error });
28657
29416
  }
28658
29417
  const config = rulesyncPermissions.getJson();
28659
- const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
29418
+ const existingPermission = isRecord$1(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
28660
29419
  const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
28661
29420
  const permission = {
28662
29421
  ...existingPermission,
@@ -28665,7 +29424,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28665
29424
  ask: buckets.ask
28666
29425
  };
28667
29426
  const uiPatch = global ? { [GROKCLI_UI_KEY]: {
28668
- ...isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
29427
+ ...isRecord$1(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
28669
29428
  [GROKCLI_PERMISSION_MODE_KEY]: deriveGrokPermissionMode(config)
28670
29429
  } } : {};
28671
29430
  return new GrokcliPermissions({
@@ -28694,7 +29453,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28694
29453
  } catch (error) {
28695
29454
  throw new Error(`Failed to parse Grok config.toml content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28696
29455
  }
28697
- const fineGrained = parseGrokPermissionArrays(isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
29456
+ const fineGrained = parseGrokPermissionArrays(isRecord$1(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
28698
29457
  const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$2]: legacyModeAction(parsed) } } };
28699
29458
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
28700
29459
  }
@@ -28791,7 +29550,7 @@ function parseGrokPermissionArrays(permission) {
28791
29550
  * `always-approve` ⇒ `allow`; anything else (including a missing mode) ⇒ `ask`.
28792
29551
  */
28793
29552
  function legacyModeAction(parsed) {
28794
- return (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
29553
+ return (isRecord$1(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
28795
29554
  }
28796
29555
  /**
28797
29556
  * Collapse a rulesync permissions config into Grok's single coarse mode.
@@ -28847,12 +29606,12 @@ function withoutKey(record, key) {
28847
29606
  return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key));
28848
29607
  }
28849
29608
  function buildHermesOverride(config, provenance) {
28850
- const base = isRecord(provenance.hermes) ? { ...provenance.hermes } : {};
28851
- const approvalsOverride = withoutKey(isRecord(config.approvals) ? config.approvals : {}, "deny");
29609
+ const base = isRecord$1(provenance.hermes) ? { ...provenance.hermes } : {};
29610
+ const approvalsOverride = withoutKey(isRecord$1(config.approvals) ? config.approvals : {}, "deny");
28852
29611
  if (Object.keys(approvalsOverride).length > 0) base.approvals = approvalsOverride;
28853
29612
  else delete base.approvals;
28854
- const security = isRecord(config.security) ? { ...config.security } : {};
28855
- const blocklist = isRecord(security.website_blocklist) ? { ...security.website_blocklist } : void 0;
29613
+ const security = isRecord$1(config.security) ? { ...config.security } : {};
29614
+ const blocklist = isRecord$1(security.website_blocklist) ? { ...security.website_blocklist } : void 0;
28856
29615
  if (blocklist?.enabled === true) {
28857
29616
  delete blocklist.domains;
28858
29617
  delete blocklist.enabled;
@@ -28861,7 +29620,7 @@ function buildHermesOverride(config, provenance) {
28861
29620
  else delete security.website_blocklist;
28862
29621
  if (Object.keys(security).length > 0) base.security = security;
28863
29622
  else delete base.security;
28864
- for (const key of ["skills", "memory"]) if (isRecord(config[key])) base[key] = config[key];
29623
+ for (const key of ["skills", "memory"]) if (isRecord$1(config[key])) base[key] = config[key];
28865
29624
  else delete base[key];
28866
29625
  return base;
28867
29626
  }
@@ -28933,7 +29692,7 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
28933
29692
  format: "yaml",
28934
29693
  fileContent: this.getFileContent()
28935
29694
  });
28936
- const permissionsRoot = isRecord(config.permissions) ? config.permissions : {};
29695
+ const permissionsRoot = isRecord$1(config.permissions) ? config.permissions : {};
28937
29696
  const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(permissionsRoot.rulesync);
28938
29697
  const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
28939
29698
  const permission = clonePermissionBlock(provenance.permission);
@@ -28941,14 +29700,14 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
28941
29700
  permission,
28942
29701
  commandAllowlist: isStringArray$1(config.command_allowlist) ? config.command_allowlist : []
28943
29702
  });
28944
- const approvals = isRecord(config.approvals) ? config.approvals : {};
29703
+ const approvals = isRecord$1(config.approvals) ? config.approvals : {};
28945
29704
  reconcileNativeDenies({
28946
29705
  permission,
28947
29706
  category: "bash",
28948
29707
  patterns: isStringArray$1(approvals.deny) ? approvals.deny : []
28949
29708
  });
28950
- const security = isRecord(config.security) ? config.security : {};
28951
- const websiteBlocklist = isRecord(security.website_blocklist) ? security.website_blocklist : {};
29709
+ const security = isRecord$1(config.security) ? config.security : {};
29710
+ const websiteBlocklist = isRecord$1(security.website_blocklist) ? security.website_blocklist : {};
28952
29711
  reconcileNativeDenies({
28953
29712
  permission,
28954
29713
  category: "webfetch",
@@ -29623,8 +30382,8 @@ function mergeKimiCodeToolsSection({ existingContent, patch }) {
29623
30382
  existing = void 0;
29624
30383
  }
29625
30384
  const merged = {
29626
- ...isRecord(existing) ? existing : {},
29627
- ...isRecord(patch.tools) ? patch.tools : {}
30385
+ ...isRecord$1(existing) ? existing : {},
30386
+ ...isRecord$1(patch.tools) ? patch.tools : {}
29628
30387
  };
29629
30388
  if (Object.keys(merged).length === 0) return;
29630
30389
  warnAboutMistypedToolLists(merged);
@@ -29648,7 +30407,7 @@ function mergeKimiCodeToolsSection({ existingContent, patch }) {
29648
30407
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#tools
29649
30408
  */
29650
30409
  function buildKimiCodeToolsSection(tools) {
29651
- if (!isRecord(tools)) return;
30410
+ if (!isRecord$1(tools)) return;
29652
30411
  const section = Object.fromEntries(Object.entries(tools).filter(([key, value]) => key === "enabled" || key === "disabled" ? isStringList(value) : true));
29653
30412
  return Object.keys(section).length > 0 ? section : void 0;
29654
30413
  }
@@ -29747,7 +30506,7 @@ function preserveKimiCodeRules(rules) {
29747
30506
  nativeRules
29748
30507
  };
29749
30508
  for (const raw of rules) {
29750
- if (!isRecord(raw)) continue;
30509
+ if (!isRecord$1(raw)) continue;
29751
30510
  const decision = raw.decision;
29752
30511
  const pattern = raw.pattern;
29753
30512
  if (decision !== "allow" && decision !== "ask" && decision !== "deny" || typeof pattern !== "string") continue;
@@ -29862,7 +30621,7 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
29862
30621
  format: "toml",
29863
30622
  fileContent: this.getFileContent()
29864
30623
  });
29865
- const { permission, nativeRules } = preserveKimiCodeRules((isRecord(config.permission) ? config.permission : {}).rules);
30624
+ const { permission, nativeRules } = preserveKimiCodeRules((isRecord$1(config.permission) ? config.permission : {}).rules);
29866
30625
  const defaultPermissionMode = config.default_permission_mode;
29867
30626
  const tools = buildKimiCodeToolsSection(config.tools);
29868
30627
  const toolOverride = {
@@ -31139,7 +31898,7 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31139
31898
  } catch (error) {
31140
31899
  throw new Error(`Failed to parse existing Rovodev config at ${filePath}: ${formatError(error)}`, { cause: error });
31141
31900
  }
31142
- const config = isRecord(parsed) ? { ...parsed } : {};
31901
+ const config = isRecord$1(parsed) ? { ...parsed } : {};
31143
31902
  const rulesyncConfig = rulesyncPermissions.getJson();
31144
31903
  const toolPermissions = convertRulesyncToRovodevToolPermissions({
31145
31904
  config: rulesyncConfig,
@@ -31176,8 +31935,8 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31176
31935
  } catch (error) {
31177
31936
  throw new Error(`Failed to parse Rovodev permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
31178
31937
  }
31179
- const config = isRecord(parsed) ? parsed : {};
31180
- const rulesyncConfig = convertRovodevToolPermissionsToRulesync(isRecord(config.toolPermissions) ? config.toolPermissions : {});
31938
+ const config = isRecord$1(parsed) ? parsed : {};
31939
+ const rulesyncConfig = convertRovodevToolPermissionsToRulesync(isRecord$1(config.toolPermissions) ? config.toolPermissions : {});
31181
31940
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
31182
31941
  }
31183
31942
  validate() {
@@ -31204,14 +31963,14 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31204
31963
  * keys it does not are kept as-is.
31205
31964
  */
31206
31965
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
31207
- const existingToolPermissions = isRecord(existing) ? { ...existing } : {};
31966
+ const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
31208
31967
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
31209
- if (!isRecord(existing)) return;
31968
+ if (!isRecord$1(existing)) return;
31210
31969
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
31211
31970
  logger?.warn(`Rovo Dev permissions: the rulesync source produced no rule Rovo Dev can express, so the toolPermissions block in ${filePath} keeps its current levels` + (strippedKeys.length > 0 ? `, minus the grants ${strippedKeys.map((key) => `"${key}"`).join(", ")}.` : `.`));
31212
31971
  return existingToolPermissions;
31213
31972
  }
31214
- const hasExistingToolsRecord = isRecord(existingToolPermissions.tools);
31973
+ const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
31215
31974
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
31216
31975
  warnAboutDroppedOwnedKeys({
31217
31976
  existingToolPermissions,
@@ -31255,7 +32014,7 @@ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, gen
31255
32014
  */
31256
32015
  function stripPermissiveOwnedValues(toolPermissions) {
31257
32016
  const strippedKeys = [];
31258
- if (isRecord(toolPermissions.tools)) {
32017
+ if (isRecord$1(toolPermissions.tools)) {
31259
32018
  const tools = { ...toolPermissions.tools };
31260
32019
  for (const toolKey of MANAGED_TOOL_KEYS) if (tools[toolKey] === "allow") {
31261
32020
  delete tools[toolKey];
@@ -31277,14 +32036,14 @@ function stripPermissiveOwnedValues(toolPermissions) {
31277
32036
  strippedKeys.push("default");
31278
32037
  }
31279
32038
  const bash = toolPermissions.bash;
31280
- if (isRecord(bash)) {
32039
+ if (isRecord$1(bash)) {
31281
32040
  const stripped = { ...bash };
31282
32041
  if (stripped.default === "allow") {
31283
32042
  delete stripped.default;
31284
32043
  strippedKeys.push("bash.default");
31285
32044
  }
31286
32045
  if (Array.isArray(stripped.commands)) {
31287
- const kept = stripped.commands.filter((entry) => !(isRecord(entry) && entry.permission === "allow"));
32046
+ const kept = stripped.commands.filter((entry) => !(isRecord$1(entry) && entry.permission === "allow"));
31288
32047
  if (kept.length !== stripped.commands.length) strippedKeys.push("bash.commands");
31289
32048
  if (kept.length > 0) stripped.commands = kept;
31290
32049
  else delete stripped.commands;
@@ -31392,15 +32151,15 @@ function convertRovodevToolPermissionsToRulesync(toolPermissions) {
31392
32151
  const permission = {};
31393
32152
  if (isPermissionAction(toolPermissions.default)) permission[CATCH_ALL_PATTERN$1] = { [CATCH_ALL_PATTERN$1]: toolPermissions.default };
31394
32153
  const bash = toolPermissions.bash;
31395
- if (isRecord(bash)) {
32154
+ if (isRecord$1(bash)) {
31396
32155
  const bashRules = {};
31397
32156
  if (isPermissionAction(bash.default)) bashRules[CATCH_ALL_PATTERN$1] = bash.default;
31398
32157
  if (Array.isArray(bash.commands)) {
31399
- for (const entry of bash.commands) if (isRecord(entry) && typeof entry.command === "string" && isPermissionAction(entry.permission)) bashRules[entry.command] = entry.permission;
32158
+ for (const entry of bash.commands) if (isRecord$1(entry) && typeof entry.command === "string" && isPermissionAction(entry.permission)) bashRules[entry.command] = entry.permission;
31400
32159
  }
31401
32160
  if (Object.keys(bashRules).length > 0) permission.bash = bashRules;
31402
32161
  }
31403
- const nestedTools = isRecord(toolPermissions.tools) ? toolPermissions.tools : {};
32162
+ const nestedTools = isRecord$1(toolPermissions.tools) ? toolPermissions.tools : {};
31404
32163
  const implicitLevel = isPermissionAction(toolPermissions.default) ? toolPermissions.default : "ask";
31405
32164
  for (const category of new Set(Object.values(TOOL_KEY_TO_CATEGORY))) {
31406
32165
  const levels = Object.entries(TOOL_KEY_TO_CATEGORY).filter(([, mapped]) => mapped === category).map(([toolKey]) => {
@@ -32115,11 +32874,11 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32115
32874
  config,
32116
32875
  logger
32117
32876
  });
32118
- const agents = isRecord(settings.agents) ? { ...settings.agents } : {};
32119
- const profiles = isRecord(agents.profiles) ? { ...agents.profiles } : {};
32877
+ const agents = isRecord$1(settings.agents) ? { ...settings.agents } : {};
32878
+ const profiles = isRecord$1(agents.profiles) ? { ...agents.profiles } : {};
32120
32879
  const override = config.warp;
32121
- const executionProfileOverride = isRecord(override) && isRecord(override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
32122
- if (isRecord(override)) {
32880
+ const executionProfileOverride = isRecord$1(override) && isRecord$1(override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
32881
+ if (isRecord$1(override)) {
32123
32882
  const { [WARP_EXECUTION_PROFILE_OVERRIDE_KEY]: _executionProfile, ...legacyOverride } = override;
32124
32883
  Object.assign(profiles, legacyOverride);
32125
32884
  }
@@ -32154,10 +32913,10 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32154
32913
  } catch (error) {
32155
32914
  throw new Error(`Failed to parse Warp permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
32156
32915
  }
32157
- const agents = isRecord(settings.agents) ? settings.agents : {};
32158
- const profiles = isRecord(agents.profiles) ? agents.profiles : {};
32159
- const executionProfiles = isRecord(agents[EXECUTION_PROFILES_KEY]) ? agents[EXECUTION_PROFILES_KEY] : void 0;
32160
- const defaultProfile = executionProfiles && isRecord(executionProfiles[DEFAULT_PROFILE_KEY]) ? executionProfiles[DEFAULT_PROFILE_KEY] : void 0;
32916
+ const agents = isRecord$1(settings.agents) ? settings.agents : {};
32917
+ const profiles = isRecord$1(agents.profiles) ? agents.profiles : {};
32918
+ const executionProfiles = isRecord$1(agents[EXECUTION_PROFILES_KEY]) ? agents[EXECUTION_PROFILES_KEY] : void 0;
32919
+ const defaultProfile = executionProfiles && isRecord$1(executionProfiles[DEFAULT_PROFILE_KEY]) ? executionProfiles[DEFAULT_PROFILE_KEY] : void 0;
32161
32920
  const config = convertWarpToRulesyncPermissions({
32162
32921
  allow: defaultProfile ? isStringArray$1(defaultProfile[PROFILE_ALLOWLIST_KEY]) ? defaultProfile[PROFILE_ALLOWLIST_KEY] : [] : isStringArray$1(profiles[ALLOWLIST_KEY]) ? profiles[ALLOWLIST_KEY] : [],
32163
32922
  deny: defaultProfile ? isStringArray$1(defaultProfile[PROFILE_DENYLIST_KEY]) ? defaultProfile[PROFILE_DENYLIST_KEY] : [] : isStringArray$1(profiles[DENYLIST_KEY]) ? profiles[DENYLIST_KEY] : []
@@ -32204,12 +32963,12 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32204
32963
  */
32205
32964
  function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, executionProfileOverride, logger }) {
32206
32965
  const hasOverrideKeys = executionProfileOverride !== void 0 && Object.keys(executionProfileOverride).length > 0;
32207
- if (!isRecord(agents[EXECUTION_PROFILES_KEY])) {
32966
+ if (!isRecord$1(agents[EXECUTION_PROFILES_KEY])) {
32208
32967
  if (hasOverrideKeys && logger) logger.warn("The warp.execution_profile permissions override was skipped: settings.toml has no [agents.execution_profiles] collection yet (un-migrated install). Open Warp once to run its settings migration, then re-run rulesync generate.");
32209
32968
  return;
32210
32969
  }
32211
32970
  const executionProfiles = { ...agents[EXECUTION_PROFILES_KEY] };
32212
- const defaultProfile = isRecord(executionProfiles[DEFAULT_PROFILE_KEY]) ? { ...executionProfiles[DEFAULT_PROFILE_KEY] } : {};
32971
+ const defaultProfile = isRecord$1(executionProfiles[DEFAULT_PROFILE_KEY]) ? { ...executionProfiles[DEFAULT_PROFILE_KEY] } : {};
32213
32972
  if (executionProfileOverride) Object.assign(defaultProfile, executionProfileOverride);
32214
32973
  if (mergedAllow.length > 0) defaultProfile[PROFILE_ALLOWLIST_KEY] = mergedAllow;
32215
32974
  else delete defaultProfile[PROFILE_ALLOWLIST_KEY];
@@ -32283,9 +33042,13 @@ const ZedToolPermissionsSchema = zod_mini.z.looseObject({
32283
33042
  default: zod_mini.z.optional(ZedPermissionActionSchema),
32284
33043
  tools: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), ZedToolPermissionSchema))
32285
33044
  });
33045
+ /** Canonical per-tool MCP category prefix: `mcp__<server>__<tool>`. */
33046
+ const MCP_CANONICAL_PREFIX = "mcp__";
33047
+ /** Zed's per-tool MCP name prefix: `mcp:<server>:<tool>`. */
33048
+ const MCP_ZED_PREFIX = "mcp:";
32286
33049
  /**
32287
33050
  * Mapping from rulesync canonical tool category names to Zed agent tool names.
32288
- * Unknown names are passed through as-is (e.g. `mcp:<server>:<tool>` keys).
33051
+ * Unknown names are passed through as-is.
32289
33052
  */
32290
33053
  const CANONICAL_TO_ZED_TOOL_NAMES = {
32291
33054
  bash: "terminal",
@@ -32295,13 +33058,93 @@ const CANONICAL_TO_ZED_TOOL_NAMES = {
32295
33058
  webfetch: "fetch",
32296
33059
  websearch: "search_web"
32297
33060
  };
33061
+ /**
33062
+ * Canonical categories whose Zed tool is not permission-gated. Zed's gated list
33063
+ * is `terminal`, `edit_file`, `write_file`, `delete_path`, `move_path`,
33064
+ * `copy_path`, `create_directory`, `fetch`, `search_web` and `skill`; the
33065
+ * read-only tools (`read_file`, `grep`, `find_path`, `list_directory`) sit in
33066
+ * Zed's own `EXCLUDED_TOOLS` and never call `decide_permission_from_settings`,
33067
+ * so a `tools.<name>` entry for one is config Zed never consults. Zed's real
33068
+ * read-denial surface is `private_files`, which the ignore feature owns.
33069
+ *
33070
+ * @see https://zed.dev/docs/ai/tool-permissions#supported-tools
33071
+ */
33072
+ const ZED_EXCLUDED_CANONICAL_CATEGORIES = /* @__PURE__ */ new Set([
33073
+ "read",
33074
+ "grep",
33075
+ "glob"
33076
+ ]);
33077
+ /** The Zed-side spellings of the same tools, for a category that names one directly. */
33078
+ const ZED_EXCLUDED_TOOL_NAMES = /* @__PURE__ */ new Set([
33079
+ "read_file",
33080
+ "grep",
33081
+ "find_path",
33082
+ "list_directory"
33083
+ ]);
33084
+ const isZedExcludedCategory = (category) => ZED_EXCLUDED_CANONICAL_CATEGORIES.has(category) || ZED_EXCLUDED_TOOL_NAMES.has(toZedToolName(category));
32298
33085
  const ZED_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_ZED_TOOL_NAMES).map(([k, v]) => [v, k]));
33086
+ /**
33087
+ * Zed addresses an MCP tool as `mcp:<server>:<tool>`, where rulesync's canonical
33088
+ * category is `mcp__<server>__<tool>`. Without this translation the canonical
33089
+ * spelling lands under a key Zed never looks up.
33090
+ *
33091
+ * Only the FIRST separator is split, matching the `cursor-permissions.ts`
33092
+ * precedent: upstream's `mcp_tool_id` concatenates the two names without
33093
+ * escaping either, so a tool called `create__issue` is legitimately
33094
+ * `mcp:github:create__issue`. Splitting every separator would rewrite that into
33095
+ * a third key on the next generate.
33096
+ *
33097
+ * @see https://zed.dev/docs/ai/tool-permissions
33098
+ */
32299
33099
  function toZedToolName(canonical) {
33100
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX)) {
33101
+ const [server, ...toolParts] = canonical.slice(5).split("__");
33102
+ const address = toolParts.length > 0 ? `${server}:${toolParts.join("__")}` : server ?? "";
33103
+ return `${MCP_ZED_PREFIX}${address}`;
33104
+ }
32300
33105
  return CANONICAL_TO_ZED_TOOL_NAMES[canonical] ?? canonical;
32301
33106
  }
32302
33107
  function toCanonicalToolName(zedName) {
33108
+ if (zedName.startsWith(MCP_ZED_PREFIX)) {
33109
+ const [server, ...toolParts] = zedName.slice(4).split(":");
33110
+ const address = toolParts.length > 0 ? `${server}__${toolParts.join(":")}` : server ?? "";
33111
+ return `${MCP_CANONICAL_PREFIX}${address}`;
33112
+ }
32303
33113
  return ZED_TO_CANONICAL_TOOL_NAMES[zedName] ?? zedName;
32304
33114
  }
33115
+ /**
33116
+ * Zed matches `always_allow`/`always_deny`/`always_confirm` regexes against the
33117
+ * tool's text input, and it dispatches every MCP tool with a single empty input
33118
+ * (`&[String::new()]`, commented upstream as "MCP tools are gated only by tool
33119
+ * id (no per-input pattern matching)"). The regexes still run, but against `""`,
33120
+ * so a pattern-scoped rule silently does something other than what its author
33121
+ * meant — and a non-matching `always_allow` downgrades the outcome to confirm.
33122
+ * Only the category's `*` rule (Zed's per-tool `default`) is therefore emitted.
33123
+ */
33124
+ const isMcpZedToolName = (zedName) => zedName.startsWith(MCP_ZED_PREFIX);
33125
+ /**
33126
+ * Zed looks a tool up by exact key on the full `mcp:<server>:<tool>` triple, so
33127
+ * an address is inert unless it names both a concrete server and a concrete
33128
+ * tool: a missing half matches nothing, and so does a wildcard, since Zed does
33129
+ * no glob or prefix matching on the key.
33130
+ */
33131
+ function isInertMcpAddress(zedToolName) {
33132
+ if (!isMcpZedToolName(zedToolName)) return false;
33133
+ const [server, ...toolParts] = zedToolName.slice(4).split(":");
33134
+ const tool = toolParts.join(":");
33135
+ return !server || server === "*" || !tool || tool === "*";
33136
+ }
33137
+ /**
33138
+ * Strip pattern-scoped rules from an MCP category, warning once about the ones
33139
+ * dropped. Non-MCP categories are returned untouched.
33140
+ */
33141
+ function withoutInertMcpPatterns({ category, zedToolName, rules, logger }) {
33142
+ if (!isMcpZedToolName(zedToolName)) return rules;
33143
+ const scopedPatterns = Object.keys(rules).filter((pattern) => pattern !== "*");
33144
+ if (scopedPatterns.length === 0) return rules;
33145
+ logger?.warn(`Zed permissions: dropping the pattern-scoped rule(s) ${scopedPatterns.map((pattern) => `"${pattern}"`).join(", ")} in the "${category}" category — Zed dispatches an MCP tool with an empty input, so a permission pattern is matched against "" rather than against anything meaningful. Only the catch-all "*" rule is emitted, as the tool's default.`);
33146
+ return Object.fromEntries(Object.entries(rules).filter(([pattern]) => pattern === "*"));
33147
+ }
32305
33148
  const CANONICAL_TO_ZED_ACTION = {
32306
33149
  allow: "allow",
32307
33150
  ask: "confirm",
@@ -32344,6 +33187,53 @@ function buildZedToolPermission(rules) {
32344
33187
  if (alwaysConfirm.length > 0) tool.always_confirm = alwaysConfirm;
32345
33188
  return Object.keys(tool).length > 0 ? tool : null;
32346
33189
  }
33190
+ /**
33191
+ * Split a canonical permission block into the Zed shapes it maps onto, plus the
33192
+ * categories the caller should report as dropped.
33193
+ *
33194
+ * The canonical `*` category is the all-tools catch-all. Zed's counterpart is
33195
+ * `agent.tool_permissions.default` (rung 6 of its precedence ladder), not a
33196
+ * `tools["*"]` entry — `*` is not a Zed tool name, so writing one produces a
33197
+ * rule Zed silently ignores. Only the category's own `*` pattern can be
33198
+ * expressed there: Zed's global default carries no pattern list, so
33199
+ * pattern-scoped rules in the `*` category are dropped with a warning instead of
33200
+ * being emitted as inert config.
33201
+ */
33202
+ function buildZedToolPermissions({ permission, logger }) {
33203
+ let managedDefault;
33204
+ const managedTools = {};
33205
+ const excludedCategories = [];
33206
+ const inertMcpCategories = [];
33207
+ for (const [category, rules] of Object.entries(permission)) {
33208
+ if (category === "*") {
33209
+ for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
33210
+ else logger?.warn(`Zed permissions: dropping the "*" category rule for pattern "${pattern}" — Zed's global tool-permission default takes no patterns; scope the rule to a tool category instead.`);
33211
+ continue;
33212
+ }
33213
+ if (isZedExcludedCategory(category)) {
33214
+ if (Object.values(rules).some((action) => action === "deny" || action === "ask")) excludedCategories.push(category);
33215
+ continue;
33216
+ }
33217
+ const zedToolName = toZedToolName(category);
33218
+ if (isInertMcpAddress(zedToolName)) {
33219
+ inertMcpCategories.push(category);
33220
+ continue;
33221
+ }
33222
+ const tool = buildZedToolPermission(withoutInertMcpPatterns({
33223
+ category,
33224
+ zedToolName,
33225
+ rules,
33226
+ logger
33227
+ }));
33228
+ if (tool) managedTools[zedToolName] = tool;
33229
+ }
33230
+ return {
33231
+ managedDefault,
33232
+ managedTools,
33233
+ excludedCategories,
33234
+ inertMcpCategories
33235
+ };
33236
+ }
32347
33237
  function asRecord(value) {
32348
33238
  if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
32349
33239
  return Object.fromEntries(Object.entries(value));
@@ -32414,19 +33304,15 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
32414
33304
  const agent = asRecord(settings.agent);
32415
33305
  const toolPermissions = asRecord(agent.tool_permissions);
32416
33306
  const existingTools = asRecord(toolPermissions.tools);
32417
- let managedDefault;
32418
- const managedTools = {};
32419
- for (const [category, rules] of Object.entries(config.permission)) {
32420
- if (category === "*") {
32421
- for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
32422
- else logger?.warn(`Zed permissions: dropping the "*" category rule for pattern "${pattern}" — Zed's global tool-permission default takes no patterns; scope the rule to a tool category instead.`);
32423
- continue;
32424
- }
32425
- const tool = buildZedToolPermission(rules);
32426
- if (tool) managedTools[toZedToolName(category)] = tool;
32427
- }
33307
+ const { managedDefault, managedTools, excludedCategories, inertMcpCategories } = buildZedToolPermissions({
33308
+ permission: config.permission,
33309
+ logger
33310
+ });
33311
+ if (excludedCategories.length > 0) logger?.warn(`Zed permissions: dropping the ${excludedCategories.map((category) => `"${category}"`).join(", ")} ${excludedCategories.length === 1 ? "category" : "categories"} — Zed does not gate its read-only tools, so the entries would never be consulted. Zed's read-denial surface is \`private_files\`, which the ignore feature writes from \`.rulesync/.aiignore\`.`);
33312
+ if (inertMcpCategories.length > 0) logger?.warn(`Zed permissions: dropping the ${inertMcpCategories.map((category) => `"${category}"`).join(", ")} ${inertMcpCategories.length === 1 ? "category" : "categories"} — Zed looks an MCP tool up by exact key on the full \`mcp:<server>:<tool>\` triple, so an address that omits or wildcards either half matches nothing. Name the individual server and tool instead.`);
32428
33313
  const managedToolNames = new Set(Object.keys(managedTools));
32429
33314
  if ("*" in config.permission) managedToolNames.add("*");
33315
+ for (const toolName of Object.keys(existingTools)) if (toolName.startsWith(MCP_CANONICAL_PREFIX)) managedToolNames.add(toolName);
32430
33316
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
32431
33317
  return new ZedPermissions({
32432
33318
  outputRoot,
@@ -33216,18 +34102,16 @@ var DirFeatureProcessor = class {
33216
34102
  })) dirHasChanges = true;
33217
34103
  }
33218
34104
  const otherFiles = aiDir.getOtherFiles();
33219
- const otherFileContents = [];
33220
34105
  for (const file of otherFiles) {
33221
- const contentWithNewline = addTrailingNewline(file.fileBuffer.toString("utf-8"));
33222
- otherFileContents.push(contentWithNewline);
33223
- if (!dirHasChanges) {
33224
- const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
33225
- if (!fileContentsEquivalent({
33226
- filePath,
33227
- expected: contentWithNewline,
33228
- existing: await readFileContentOrNull(filePath)
33229
- })) dirHasChanges = true;
33230
- }
34106
+ if (dirHasChanges) break;
34107
+ const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
34108
+ const existingBuffer = await readFileBufferOrNull(filePath);
34109
+ if (!companionFileContentsEquivalent({
34110
+ filePath,
34111
+ expected: file.fileBuffer,
34112
+ existing: existingBuffer,
34113
+ composed: file.composed
34114
+ })) dirHasChanges = true;
33231
34115
  }
33232
34116
  if (!dirHasChanges) continue;
33233
34117
  const relativeDir = aiDir.getRelativePathFromCwd();
@@ -33247,11 +34131,8 @@ var DirFeatureProcessor = class {
33247
34131
  await writeFileContent((0, node_path.join)(dirPath, mainFile.name), mainFileContent);
33248
34132
  changedPaths.push((0, node_path.join)(relativeDir, mainFile.name));
33249
34133
  }
33250
- for (const [i, file] of otherFiles.entries()) {
33251
- const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
33252
- const content = otherFileContents[i];
33253
- if (content === void 0) throw new Error(`Internal error: content for file ${file.relativeFilePathToDirPath} is undefined. This indicates a synchronization issue between otherFiles and otherFileContents arrays.`);
33254
- await writeFileContent(filePath, content);
34134
+ for (const file of otherFiles) {
34135
+ await writeFileBuffer((0, node_path.join)(dirPath, file.relativeFilePathToDirPath), file.fileBuffer);
33255
34136
  changedPaths.push((0, node_path.join)(relativeDir, file.relativeFilePathToDirPath));
33256
34137
  }
33257
34138
  }
@@ -33977,6 +34858,39 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
33977
34858
  return frontmatter;
33978
34859
  }
33979
34860
  /**
34861
+ * Escapes the glob metacharacters in a directory path so it matches literally.
34862
+ * A real directory name may contain them — `app/[slug]` in a Next.js tree is
34863
+ * the common case, and unescaped `[slug]` reads as a bracket expression that
34864
+ * matches a different subtree (or nothing at all).
34865
+ *
34866
+ * @see https://code.claude.com/docs/en/memory
34867
+ */
34868
+ function escapeGlobLiteral(dirPath) {
34869
+ return dirPath.replaceAll(/[\\*?[\]{}()!]/g, "\\$&");
34870
+ }
34871
+ /**
34872
+ * Claude Code scopes a nested skill by its location: a skill living in
34873
+ * `apps/web/.claude/skills/deploy` only activates while working under
34874
+ * `apps/web`. rulesync generates every imported skill into the project-root
34875
+ * `.claude/skills/`, so on import that location-based scoping has to be
34876
+ * re-expressed as an explicit `paths` glob — otherwise the round-trip silently
34877
+ * promotes a subtree skill to global activation.
34878
+ *
34879
+ * Returns the derived glob for a nested discovery root, or `undefined` for the
34880
+ * project-root `.claude/skills` (and for any root whose subtree cannot be
34881
+ * determined), where no scoping is implied.
34882
+ *
34883
+ * @see https://code.claude.com/docs/en/skills
34884
+ */
34885
+ function deriveNestedSkillPaths(relativeDirPath) {
34886
+ const posixDirPath = toPosixPath(relativeDirPath);
34887
+ const skillsDirSuffix = `/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`;
34888
+ if (!posixDirPath.endsWith(skillsDirSuffix)) return;
34889
+ const subtree = posixDirPath.slice(0, -skillsDirSuffix.length);
34890
+ if (subtree === "" || subtree === ".") return;
34891
+ return [`${escapeGlobLiteral(subtree)}/**`];
34892
+ }
34893
+ /**
33980
34894
  * Represents a Claude Code skill directory.
33981
34895
  * Unlike subagents and commands, skills are directories containing SKILL.md and other files.
33982
34896
  * Extends ToolSkill to inherit directory management and security features from AiDir.
@@ -34064,6 +34978,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34064
34978
  }
34065
34979
  toRulesyncSkill() {
34066
34980
  const frontmatter = this.getFrontmatter();
34981
+ const resolvedPaths = frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath);
34067
34982
  const claudecodeSection = {
34068
34983
  ...frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use },
34069
34984
  ...frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] },
@@ -34080,7 +34995,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34080
34995
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34081
34996
  ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34082
34997
  ...this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true },
34083
- ...frontmatter.paths !== void 0 && { paths: frontmatter.paths }
34998
+ ...resolvedPaths !== void 0 && { paths: resolvedPaths }
34084
34999
  };
34085
35000
  const rulesyncFrontmatter = {
34086
35001
  name: frontmatter.name,
@@ -34478,7 +35393,8 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
34478
35393
  fileBuffer: Buffer.from((0, js_yaml.dump)(openaiObject, {
34479
35394
  lineWidth: -1,
34480
35395
  noRefs: true
34481
- }))
35396
+ })),
35397
+ composed: true
34482
35398
  }] : baseOtherFiles;
34483
35399
  return new CodexCliSkill({
34484
35400
  outputRoot,
@@ -34538,7 +35454,11 @@ const CopilotSkillFrontmatterSchema = zod_mini.z.looseObject({
34538
35454
  name: zod_mini.z.string(),
34539
35455
  description: zod_mini.z.string(),
34540
35456
  license: zod_mini.z.optional(zod_mini.z.string()),
34541
- "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
35457
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
35458
+ "argument-hint": zod_mini.z.optional(zod_mini.z.string()),
35459
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
35460
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
35461
+ context: zod_mini.z.optional(zod_mini.z.string())
34542
35462
  });
34543
35463
  /**
34544
35464
  * Represents a GitHub Copilot skill directory.
@@ -34593,14 +35513,10 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34593
35513
  };
34594
35514
  }
34595
35515
  toRulesyncSkill() {
34596
- const frontmatter = this.getFrontmatter();
34597
- const copilotSection = {
34598
- ...frontmatter.license !== void 0 && { license: frontmatter.license },
34599
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
34600
- };
35516
+ const { name, description, ...copilotSection } = this.getFrontmatter();
34601
35517
  const rulesyncFrontmatter = {
34602
- name: frontmatter.name,
34603
- description: frontmatter.description,
35518
+ name,
35519
+ description,
34604
35520
  targets: ["*"],
34605
35521
  ...Object.keys(copilotSection).length > 0 && { copilot: copilotSection }
34606
35522
  };
@@ -34618,11 +35534,22 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34618
35534
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34619
35535
  const settablePaths = CopilotSkill.getSettablePaths({ global });
34620
35536
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35537
+ const copilotSection = rulesyncFrontmatter.copilot;
35538
+ const resolvedUserInvocable = resolveUserInvocable({
35539
+ rootFrontmatter: rulesyncFrontmatter,
35540
+ section: copilotSection
35541
+ });
35542
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35543
+ rootFrontmatter: rulesyncFrontmatter,
35544
+ section: copilotSection
35545
+ });
35546
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotFields } = copilotSection ?? {};
34621
35547
  const copilotFrontmatter = {
35548
+ ...copilotFields,
34622
35549
  name: rulesyncFrontmatter.name,
34623
35550
  description: rulesyncFrontmatter.description,
34624
- ...rulesyncFrontmatter.copilot?.license !== void 0 && { license: rulesyncFrontmatter.copilot.license },
34625
- ...rulesyncFrontmatter.copilot?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilot["allowed-tools"] }
35551
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35552
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
34626
35553
  };
34627
35554
  return new CopilotSkill({
34628
35555
  outputRoot,
@@ -34741,17 +35668,10 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
34741
35668
  };
34742
35669
  }
34743
35670
  toRulesyncSkill() {
34744
- const frontmatter = this.getFrontmatter();
34745
- const copilotcliSection = {
34746
- ...frontmatter.license !== void 0 && { license: frontmatter.license },
34747
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] },
34748
- ...frontmatter["argument-hint"] !== void 0 && { "argument-hint": frontmatter["argument-hint"] },
34749
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34750
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] }
34751
- };
35671
+ const { name, description, ...copilotcliSection } = this.getFrontmatter();
34752
35672
  const rulesyncFrontmatter = {
34753
- name: frontmatter.name,
34754
- description: frontmatter.description,
35673
+ name,
35674
+ description,
34755
35675
  targets: ["*"],
34756
35676
  ...Object.keys(copilotcliSection).length > 0 && { copilotcli: copilotcliSection }
34757
35677
  };
@@ -34769,14 +35689,22 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
34769
35689
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34770
35690
  const settablePaths = CopilotcliSkill.getSettablePaths({ global });
34771
35691
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35692
+ const copilotcliSection = rulesyncFrontmatter.copilotcli;
35693
+ const resolvedUserInvocable = resolveUserInvocable({
35694
+ rootFrontmatter: rulesyncFrontmatter,
35695
+ section: copilotcliSection
35696
+ });
35697
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35698
+ rootFrontmatter: rulesyncFrontmatter,
35699
+ section: copilotcliSection
35700
+ });
35701
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotcliFields } = copilotcliSection ?? {};
34772
35702
  const copilotcliFrontmatter = {
35703
+ ...copilotcliFields,
34773
35704
  name: rulesyncFrontmatter.name,
34774
35705
  description: rulesyncFrontmatter.description,
34775
- ...rulesyncFrontmatter.copilotcli?.license !== void 0 && { license: rulesyncFrontmatter.copilotcli.license },
34776
- ...rulesyncFrontmatter.copilotcli?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilotcli["allowed-tools"] },
34777
- ...rulesyncFrontmatter.copilotcli?.["argument-hint"] !== void 0 && { "argument-hint": rulesyncFrontmatter.copilotcli["argument-hint"] },
34778
- ...rulesyncFrontmatter.copilotcli?.["user-invocable"] !== void 0 && { "user-invocable": rulesyncFrontmatter.copilotcli["user-invocable"] },
34779
- ...rulesyncFrontmatter.copilotcli?.["disable-model-invocation"] !== void 0 && { "disable-model-invocation": rulesyncFrontmatter.copilotcli["disable-model-invocation"] }
35706
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35707
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
34780
35708
  };
34781
35709
  return new CopilotcliSkill({
34782
35710
  outputRoot,
@@ -35334,7 +36262,9 @@ const FactorydroidSkillFrontmatterSchema = zod_mini.z.looseObject({
35334
36262
  name: zod_mini.z.string(),
35335
36263
  description: zod_mini.z.string(),
35336
36264
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
35337
- "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean())
36265
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
36266
+ enabled: zod_mini.z.optional(zod_mini.z.boolean()),
36267
+ "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
35338
36268
  });
35339
36269
  /**
35340
36270
  * Represents a Factory Droid skill directory.
@@ -35390,7 +36320,9 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35390
36320
  const frontmatter = this.getFrontmatter();
35391
36321
  const factorydroidBlock = {
35392
36322
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
35393
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] }
36323
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
36324
+ ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
36325
+ ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
35394
36326
  };
35395
36327
  const rulesyncFrontmatter = {
35396
36328
  name: frontmatter.name,
@@ -35412,19 +36344,22 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35412
36344
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
35413
36345
  const settablePaths = FactorydroidSkill.getSettablePaths({ global });
35414
36346
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
36347
+ const factorydroidSection = rulesyncFrontmatter.factorydroid;
35415
36348
  const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35416
36349
  rootFrontmatter: rulesyncFrontmatter,
35417
- section: rulesyncFrontmatter.factorydroid
36350
+ section: factorydroidSection
35418
36351
  });
35419
36352
  const resolvedUserInvocable = resolveUserInvocable({
35420
36353
  rootFrontmatter: rulesyncFrontmatter,
35421
- section: rulesyncFrontmatter.factorydroid
36354
+ section: factorydroidSection
35422
36355
  });
35423
36356
  const factorydroidFrontmatter = {
35424
36357
  name: rulesyncFrontmatter.name,
35425
36358
  description: rulesyncFrontmatter.description,
35426
36359
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
35427
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
36360
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
36361
+ ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
36362
+ ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
35428
36363
  };
35429
36364
  return new FactorydroidSkill({
35430
36365
  outputRoot,
@@ -36241,7 +37176,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
36241
37176
  //#region src/features/skills/kiro-skill.ts
36242
37177
  const KiroSkillFrontmatterSchema = zod_mini.z.looseObject({
36243
37178
  name: zod_mini.z.string(),
36244
- description: zod_mini.z.string()
37179
+ description: zod_mini.z.string(),
37180
+ license: zod_mini.z.optional(zod_mini.z.string()),
37181
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
37182
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
36245
37183
  });
36246
37184
  /**
36247
37185
  * Represents a Kiro skill directory.
@@ -36295,11 +37233,12 @@ var KiroSkill = class KiroSkill extends ToolSkill {
36295
37233
  };
36296
37234
  }
36297
37235
  toRulesyncSkill() {
36298
- const frontmatter = this.getFrontmatter();
37236
+ const { name, description, ...kiroSection } = this.getFrontmatter();
36299
37237
  const rulesyncFrontmatter = {
36300
- name: frontmatter.name,
36301
- description: frontmatter.description,
36302
- targets: ["*"]
37238
+ name,
37239
+ description,
37240
+ targets: ["*"],
37241
+ ...Object.keys(kiroSection).length > 0 && { kiro: kiroSection }
36303
37242
  };
36304
37243
  return new RulesyncSkill({
36305
37244
  outputRoot: this.outputRoot,
@@ -36316,6 +37255,7 @@ var KiroSkill = class KiroSkill extends ToolSkill {
36316
37255
  const settablePaths = KiroSkill.getSettablePaths({ global });
36317
37256
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
36318
37257
  const kiroFrontmatter = {
37258
+ ...rulesyncFrontmatter.kiro,
36319
37259
  name: rulesyncFrontmatter.name,
36320
37260
  description: rulesyncFrontmatter.description
36321
37261
  };
@@ -39445,7 +40385,17 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39445
40385
  validate: true
39446
40386
  });
39447
40387
  }
39448
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
40388
+ /**
40389
+ * Last chance to adjust the tool frontmatter before it is written. The base
40390
+ * implementation only warns about names Claude Code rejects; plugin-scoped
40391
+ * subclasses extend it to drop fields Claude Code refuses to honor for
40392
+ * plugin-shipped agents.
40393
+ */
40394
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
40395
+ if (frontmatter.name.includes(":")) logger?.warn(`Claude Code will reject the subagent in ${relativeFilePath}: the name "${frontmatter.name}" contains ":", which is reserved for plugin namespacing.`);
40396
+ return frontmatter;
40397
+ }
40398
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
39449
40399
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
39450
40400
  const claudecodeSection = this.filterToolSpecificSection(rulesyncFrontmatter.claudecode ?? {}, ["name", "description"]);
39451
40401
  const rawClaudecodeFrontmatter = {
@@ -39455,7 +40405,11 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39455
40405
  };
39456
40406
  const result = ClaudecodeSubagentFrontmatterSchema.safeParse(rawClaudecodeFrontmatter);
39457
40407
  if (!result.success) throw new Error(`Invalid claudecode subagent frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(result.error)}`);
39458
- const claudecodeFrontmatter = result.data;
40408
+ const claudecodeFrontmatter = this.sanitizeFrontmatter({
40409
+ frontmatter: result.data,
40410
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
40411
+ logger
40412
+ });
39459
40413
  const body = rulesyncSubagent.getBody();
39460
40414
  const fileContent = stringifyFrontmatter(body, claudecodeFrontmatter);
39461
40415
  const paths = this.getSettablePaths({ global });
@@ -39524,6 +40478,21 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39524
40478
  };
39525
40479
  //#endregion
39526
40480
  //#region src/features/subagents/claudecode-plugin-subagent.ts
40481
+ /**
40482
+ * Claude Code refuses these for plugin-shipped agents "for security reasons",
40483
+ * so emitting them leaves the author believing the agent is constrained when it
40484
+ * is not. Only these three are dropped: the other fields upstream does not list
40485
+ * (e.g. `color`) are merely ignored, with no misleading security posture.
40486
+ *
40487
+ * @see https://code.claude.com/docs/en/plugins-reference
40488
+ */
40489
+ const PLUGIN_FORBIDDEN_FIELDS = [
40490
+ "hooks",
40491
+ "mcpServers",
40492
+ "permissionMode"
40493
+ ];
40494
+ /** The only `isolation` value plugin agents accept. */
40495
+ const PLUGIN_ISOLATION_VALUE = "worktree";
39527
40496
  var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39528
40497
  static isTargetedByRulesyncSubagent(rulesyncSubagent) {
39529
40498
  const targets = rulesyncSubagent.getFrontmatter().targets;
@@ -39532,6 +40501,21 @@ var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39532
40501
  static getSettablePaths() {
39533
40502
  return { relativeDirPath: CLAUDECODE_PLUGIN_AGENTS_DIR };
39534
40503
  }
40504
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
40505
+ const sanitized = { ...super.sanitizeFrontmatter({
40506
+ frontmatter,
40507
+ relativeFilePath,
40508
+ logger
40509
+ }) };
40510
+ const dropped = PLUGIN_FORBIDDEN_FIELDS.filter((field) => sanitized[field] !== void 0);
40511
+ for (const field of PLUGIN_FORBIDDEN_FIELDS) delete sanitized[field];
40512
+ if (dropped.length > 0) logger?.warn(`Dropping ${dropped.join(", ")} from claudecode-plugin subagent ${relativeFilePath}: Claude Code does not support these fields for plugin-shipped agents.`);
40513
+ if (sanitized.isolation !== void 0 && sanitized.isolation !== PLUGIN_ISOLATION_VALUE) {
40514
+ logger?.warn(`Dropping isolation "${sanitized.isolation}" from claudecode-plugin subagent ${relativeFilePath}: "${PLUGIN_ISOLATION_VALUE}" is the only value Claude Code accepts for plugin-shipped agents.`);
40515
+ delete sanitized.isolation;
40516
+ }
40517
+ return sanitized;
40518
+ }
39535
40519
  };
39536
40520
  //#endregion
39537
40521
  //#region src/features/subagents/cline-subagent.ts
@@ -42840,14 +43824,14 @@ var ZoocodeSubagent = class extends RooSubagent {
42840
43824
  static toRooMode(rulesyncSubagent) {
42841
43825
  const mode = super.toRooMode(rulesyncSubagent);
42842
43826
  const frontmatter = rulesyncSubagent.getFrontmatter();
42843
- const zoocodeSection = isRecord(frontmatter.zoocode) ? frontmatter.zoocode : {};
43827
+ const zoocodeSection = isRecord$1(frontmatter.zoocode) ? frontmatter.zoocode : {};
42844
43828
  if (isStringArray$1(zoocodeSection.allowedMcpServers)) mode.allowedMcpServers = zoocodeSection.allowedMcpServers;
42845
43829
  return mode;
42846
43830
  }
42847
43831
  toRulesyncSubagents() {
42848
43832
  return super.toRulesyncSubagents().map((subagent) => {
42849
43833
  const frontmatter = subagent.getFrontmatter();
42850
- const { allowedMcpServers, ...restRooSection } = isRecord(frontmatter.roo) ? { ...frontmatter.roo } : {};
43834
+ const { allowedMcpServers, ...restRooSection } = isRecord$1(frontmatter.roo) ? { ...frontmatter.roo } : {};
42851
43835
  const rebuilt = {
42852
43836
  ...frontmatter,
42853
43837
  targets: ["zoocode"],
@@ -43185,7 +44169,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
43185
44169
  outputRoot: this.outputRoot,
43186
44170
  relativeDirPath: RulesyncSubagent.getSettablePaths().relativeDirPath,
43187
44171
  rulesyncSubagent,
43188
- global: this.global
44172
+ global: this.global,
44173
+ logger: this.logger
43189
44174
  }));
43190
44175
  }
43191
44176
  async convertToolFilesToRulesyncFiles(toolFiles) {
@@ -45138,7 +46123,7 @@ var CodexcliRule = class CodexcliRule extends ToolRule {
45138
46123
  };
45139
46124
  //#endregion
45140
46125
  //#region src/features/rules/copilot-rule.ts
45141
- const CopilotRuleFrontmatterSchema = zod_mini.z.object({
46126
+ const CopilotRuleFrontmatterSchema = zod_mini.z.looseObject({
45142
46127
  description: zod_mini.z.optional(zod_mini.z.string()),
45143
46128
  applyTo: zod_mini.z.optional(zod_mini.z.string()),
45144
46129
  name: zod_mini.z.optional(zod_mini.z.string()),
@@ -45194,15 +46179,13 @@ var CopilotRule = class CopilotRule extends ToolRule {
45194
46179
  toRulesyncRule() {
45195
46180
  let globs;
45196
46181
  if (this.frontmatter.applyTo) globs = this.frontmatter.applyTo.split(",").map((g) => g.trim());
46182
+ const { description, applyTo: _applyTo, ...copilotFields } = this.frontmatter;
45197
46183
  const rulesyncFrontmatter = {
45198
46184
  targets: ["*"],
45199
46185
  root: this.isRoot(),
45200
- description: this.frontmatter.description,
46186
+ description,
45201
46187
  globs,
45202
- ...(this.frontmatter.excludeAgent || this.frontmatter.name) && { copilot: {
45203
- ...this.frontmatter.excludeAgent && { excludeAgent: this.frontmatter.excludeAgent },
45204
- ...this.frontmatter.name && { name: this.frontmatter.name }
45205
- } }
46188
+ ...Object.keys(copilotFields).length > 0 && { copilot: copilotFields }
45206
46189
  };
45207
46190
  const relativeFilePath = this.getRelativeFilePath().replace(/\.instructions\.md$/, ".md");
45208
46191
  return new RulesyncRule({
@@ -45219,10 +46202,9 @@ var CopilotRule = class CopilotRule extends ToolRule {
45219
46202
  const root = rulesyncFrontmatter.root;
45220
46203
  const paths = this.getSettablePaths({ global });
45221
46204
  const copilotFrontmatter = {
46205
+ ...rulesyncFrontmatter.copilot,
45222
46206
  description: rulesyncFrontmatter.description,
45223
- applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0,
45224
- excludeAgent: rulesyncFrontmatter.copilot?.excludeAgent,
45225
- name: rulesyncFrontmatter.copilot?.name
46207
+ applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0
45226
46208
  };
45227
46209
  const body = rulesyncRule.getBody();
45228
46210
  if (root) return new CopilotRule({
@@ -51621,6 +52603,12 @@ Object.defineProperty(exports, "warnOnConflictingFlags", {
51621
52603
  return warnOnConflictingFlags;
51622
52604
  }
51623
52605
  });
52606
+ Object.defineProperty(exports, "writeFileBuffer", {
52607
+ enumerable: true,
52608
+ get: function() {
52609
+ return writeFileBuffer;
52610
+ }
52611
+ });
51624
52612
  Object.defineProperty(exports, "writeFileContent", {
51625
52613
  enumerable: true,
51626
52614
  get: function() {