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.
@@ -614,6 +614,13 @@ async function readFileBuffer(filepath) {
614
614
  return readFile(filepath);
615
615
  }
616
616
  /**
617
+ * Read file as a buffer if it exists, otherwise return null.
618
+ */
619
+ async function readFileBufferOrNull(filepath) {
620
+ if (await fileExists(filepath)) return readFileBuffer(filepath);
621
+ return null;
622
+ }
623
+ /**
617
624
  * Normalizes text to LF line endings and adds exactly one trailing newline.
618
625
  * Removes any existing trailing whitespace and appends a single newline.
619
626
  */
@@ -625,6 +632,10 @@ async function writeFileContent(filepath, content) {
625
632
  await ensureDir(dirname(filepath));
626
633
  await writeFile(filepath, content, "utf-8");
627
634
  }
635
+ async function writeFileBuffer(filepath, buffer) {
636
+ await ensureDir(dirname(filepath));
637
+ await writeFile(filepath, buffer);
638
+ }
628
639
  async function fileExists(filepath) {
629
640
  try {
630
641
  await stat(filepath);
@@ -1831,7 +1842,7 @@ var RulesyncFile = class extends AiFile {
1831
1842
  * Type guard to check if a value is a plain object (Record<string, unknown>).
1832
1843
  * This excludes arrays and null values.
1833
1844
  */
1834
- function isRecord(value) {
1845
+ function isRecord$1(value) {
1835
1846
  return typeof value === "object" && value !== null && !Array.isArray(value);
1836
1847
  }
1837
1848
  /**
@@ -1846,7 +1857,7 @@ function isRecord(value) {
1846
1857
  * malicious accessor descriptors.
1847
1858
  */
1848
1859
  function isPlainObject$1(value) {
1849
- if (!isRecord(value)) return false;
1860
+ if (!isRecord$1(value)) return false;
1850
1861
  const proto = Object.getPrototypeOf(value);
1851
1862
  return proto === null || proto === Object.prototype;
1852
1863
  }
@@ -2152,11 +2163,13 @@ const HookDefinitionSchema = z.looseObject({
2152
2163
  timeout: z.optional(z.number()),
2153
2164
  cacheTtl: z.optional(z.number().check(nonnegative())),
2154
2165
  matcher: z.optional(safeString),
2166
+ enabled: z.optional(z.boolean()),
2155
2167
  prompt: z.optional(safeString),
2156
2168
  loop_limit: z.optional(z.nullable(z.number())),
2157
2169
  name: z.optional(safeString),
2158
2170
  description: z.optional(safeString),
2159
2171
  failClosed: z.optional(z.boolean()),
2172
+ commandRegex: z.optional(safeString),
2160
2173
  sequential: z.optional(z.boolean()),
2161
2174
  async: z.optional(z.boolean()),
2162
2175
  env: z.optional(z.record(z.string(), safeString)),
@@ -2173,6 +2186,7 @@ const HookDefinitionSchema = z.looseObject({
2173
2186
  metadata: z.optional(z.looseObject({})),
2174
2187
  if: z.optional(safeString),
2175
2188
  commandWindows: z.optional(safeString),
2189
+ additionalContextLimit: z.optional(z.int().check(nonnegative())),
2176
2190
  asyncRewake: z.optional(z.boolean()),
2177
2191
  continueOnBlock: z.optional(z.boolean())
2178
2192
  });
@@ -2468,8 +2482,9 @@ const FACTORYDROID_HOOK_EVENTS = [
2468
2482
  /**
2469
2483
  * Hook events supported by deepagents-cli (`deepagents-code` / `dcode`).
2470
2484
  *
2471
- * The canonical `notification` event maps to dcode's `input.required`
2472
- * (human-in-the-loop interrupt) the closest documented equivalent.
2485
+ * These are the twelve Hooks v2 `HookEvent` members, GA since deepagents-code
2486
+ * 0.1.52. Canonical `contextOffload` is deliberately absent — see
2487
+ * {@link CANONICAL_TO_DEEPAGENTS_EVENT_NAMES}.
2473
2488
  * https://docs.langchain.com/oss/python/deepagents/cli/configuration
2474
2489
  */
2475
2490
  const DEEPAGENTS_HOOK_EVENTS = [
@@ -2482,8 +2497,9 @@ const DEEPAGENTS_HOOK_EVENTS = [
2482
2497
  "postToolUseFailure",
2483
2498
  "stop",
2484
2499
  "preCompact",
2485
- "contextOffload",
2486
- "notification"
2500
+ "notification",
2501
+ "subagentStart",
2502
+ "subagentStop"
2487
2503
  ];
2488
2504
  /** Hook events supported by Codex CLI. */
2489
2505
  const CODEXCLI_HOOK_EVENTS = [
@@ -3171,26 +3187,59 @@ const CANONICAL_TO_GOOSE_EVENT_NAMES = {
3171
3187
  */
3172
3188
  const GOOSE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_GOOSE_EVENT_NAMES).map(([k, v]) => [v, k]));
3173
3189
  /**
3174
- * Map canonical camelCase event names to deepagents-cli dot-notation.
3190
+ * Map canonical camelCase event names to the deepagents-cli Hooks v2
3191
+ * `HookEvent` values.
3192
+ *
3193
+ * Hooks v2 went GA in deepagents-code 0.1.52 (2026-08-04) and replaced the
3194
+ * legacy dot-notation names (`session.start`, `tool.use`, …) with these twelve
3195
+ * PascalCase members. The legacy list format is still read, but is scheduled for
3196
+ * removal on 2026-09-01 (`_LEGACY_HOOKS_REMOVAL_DATE` in `hooks/loading.py`).
3197
+ *
3198
+ * Canonical `contextOffload` has no v2 counterpart. Its legacy event
3199
+ * (`context.offload`) is gone, and folding it onto `PreCompact` would silently
3200
+ * merge two distinct canonical events into one — so it is dropped for
3201
+ * deepagents instead, and reported by the hooks processor as an unsupported
3202
+ * event like any other.
3203
+ *
3204
+ * @see https://github.com/langchain-ai/deepagents `libs/code/deepagents_code/hooks/models/domain.py`
3175
3205
  */
3176
3206
  const CANONICAL_TO_DEEPAGENTS_EVENT_NAMES = {
3177
- sessionStart: "session.start",
3178
- sessionEnd: "session.end",
3179
- beforeSubmitPrompt: "user.prompt",
3180
- permissionRequest: "permission.request",
3181
- preToolUse: "tool.use",
3182
- postToolUse: "tool.result",
3183
- postToolUseFailure: "tool.error",
3184
- stop: "task.complete",
3185
- preCompact: "context.compact",
3186
- contextOffload: "context.offload",
3187
- notification: "input.required"
3207
+ sessionStart: "SessionStart",
3208
+ beforeSubmitPrompt: "UserPromptSubmit",
3209
+ sessionEnd: "SessionEnd",
3210
+ permissionRequest: "PermissionRequest",
3211
+ notification: "Notification",
3212
+ preToolUse: "PreToolUse",
3213
+ postToolUse: "PostToolUse",
3214
+ postToolUseFailure: "PostToolUseFailure",
3215
+ preCompact: "PreCompact",
3216
+ stop: "Stop",
3217
+ subagentStart: "SubagentStart",
3218
+ subagentStop: "SubagentStop"
3188
3219
  };
3189
3220
  /**
3190
- * Map deepagents-cli dot-notation event names to canonical camelCase.
3221
+ * Map deepagents-cli `HookEvent` values to canonical camelCase.
3191
3222
  */
3192
3223
  const DEEPAGENTS_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_DEEPAGENTS_EVENT_NAMES).map(([k, v]) => [v, k]));
3193
3224
  /**
3225
+ * The legacy dot-notation event names deepagents-cli used before Hooks v2.
3226
+ * Kept for the read-only import path so a `hooks.json` still in the old format
3227
+ * round-trips into canonical events instead of being silently discarded.
3228
+ */
3229
+ const DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES = {
3230
+ "session.start": "sessionStart",
3231
+ "session.end": "sessionEnd",
3232
+ "user.prompt": "beforeSubmitPrompt",
3233
+ "permission.request": "permissionRequest",
3234
+ "tool.use": "preToolUse",
3235
+ "tool.result": "postToolUse",
3236
+ "tool.error": "postToolUseFailure",
3237
+ "task.complete": "stop",
3238
+ "context.compact": "preCompact",
3239
+ "context.offload": "contextOffload",
3240
+ "input.required": "notification"
3241
+ };
3242
+ /**
3194
3243
  * Map canonical camelCase event names to Kiro CLI camelCase.
3195
3244
  * Kiro CLI uses its own event naming: agentSpawn, userPromptSubmit, preToolUse,
3196
3245
  * postToolUse, stop. Both `sessionEnd` and `stop` canonical events map to
@@ -3808,7 +3857,7 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3808
3857
  logger
3809
3858
  });
3810
3859
  for (const ignoredKey of MCP_IGNORED_ALIAS_SOURCE_KEYS) {
3811
- if (!isRecord(json[ignoredKey])) continue;
3860
+ if (!isRecord$1(json[ignoredKey])) continue;
3812
3861
  this.warnOncePerFile(`alias:${ignoredKey}`, `The "${ignoredKey}" block in ${join(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${MCP_BLOCK_KEY_ALIASES[ignoredKey]}" key instead.`, logger);
3813
3862
  }
3814
3863
  const toolBlockKeys = Object.keys(json).filter((key) => MCP_TOOL_BLOCK_KEYS.has(key));
@@ -3820,7 +3869,7 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
3820
3869
  }));
3821
3870
  for (const blockKey of blockKeys) {
3822
3871
  const toolBlock = json[blockKey];
3823
- const toolServers = isRecord(toolBlock) && isRecord(toolBlock.mcpServers) ? toolBlock.mcpServers : void 0;
3872
+ const toolServers = isRecord$1(toolBlock) && isRecord$1(toolBlock.mcpServers) ? toolBlock.mcpServers : void 0;
3824
3873
  for (const [serverName, serverConfig] of Object.entries(toolServers ?? {})) {
3825
3874
  if (isPrototypePollutionKey(serverName)) continue;
3826
3875
  if (serverConfig === null) delete effectiveServers[serverName];
@@ -4220,7 +4269,8 @@ const ReasonixPermissionsOverrideSchema = z.looseObject({
4220
4269
  */
4221
4270
  const FactorydroidPermissionsOverrideSchema = z.looseObject({
4222
4271
  permission: z.optional(ToolScopedPermissionSchema),
4223
- commandBlocklist: z.optional(z.array(z.string()))
4272
+ commandBlocklist: z.optional(z.array(z.string())),
4273
+ disabledSkills: z.optional(z.array(z.string()))
4224
4274
  });
4225
4275
  /**
4226
4276
  * Tool-scoped override block for Warp. Warp's `[agents.profiles]` table exposes
@@ -4666,13 +4716,22 @@ const CodexBasePermissionProfileSchema = z.enum(CODEX_BASE_PERMISSION_PROFILES);
4666
4716
  * `base_permission_profile` it is consumed by the profile builder, not
4667
4717
  * written as a top-level config key.
4668
4718
  *
4669
- * Two surfaces are deliberately NOT authorable here so the override can never
4670
- * clobber a feature-owned key: `mcp_servers.*` per-MCP gating is owned by the
4671
- * MCP feature (`codexcli-mcp.ts` already writes the `mcp_servers` tables in the
4672
- * same `config.toml`), and `permissions` / `default_permissions` are owned by
4673
- * the canonical model. Any such key placed in the override is skipped with a
4674
- * warning. Kept `looseObject` (verbatim passthrough) so future top-level Codex
4675
- * config keys can be authored without Rulesync modeling each one.
4719
+ * The keys written to `config.toml` are an **allowlist**, not verbatim
4720
+ * passthrough: only `CODEXCLI_OVERRIDE_KEYS`
4721
+ * (`src/constants/codexcli-paths.ts` `approval_policy`, `sandbox_mode`,
4722
+ * `sandbox_workspace_write`, `apps`, `approvals_reviewer`) are emitted, and
4723
+ * `computeCodexcliOverridePatch` skips anything else with a warning.
4724
+ * `base_permission_profile` and `git_write_rules` are consumed by the profile
4725
+ * builder rather than written, as described above, and `permission` is the
4726
+ * tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
4727
+ * of the override before it ever reaches the patch. The allowlist is what keeps
4728
+ * the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
4729
+ * gating is owned by the MCP feature (`codexcli-mcp.ts` already writes the
4730
+ * `mcp_servers` tables in the same `config.toml`), and `permissions` /
4731
+ * `default_permissions` are owned by the canonical model. The schema itself is
4732
+ * `looseObject` so an unmodeled key parses (and is then reported rather than
4733
+ * rejected outright); supporting a new top-level Codex config key means adding
4734
+ * it to `CODEXCLI_OVERRIDE_KEYS`.
4676
4735
  *
4677
4736
  * @see https://developers.openai.com/codex/config-reference
4678
4737
  * @see https://developers.openai.com/codex/permissions
@@ -4827,9 +4886,9 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
4827
4886
  if (NATIVE_PERMISSION_OVERRIDE_TARGETS.has(toolTarget)) return this;
4828
4887
  const overrideKey = PERMISSION_OVERRIDE_KEY_ALIASES[toolTarget] ?? toolTarget;
4829
4888
  const json = this.json;
4830
- if (overrideKey !== toolTarget && isRecord(json[toolTarget])) logger?.warn(`The "${toolTarget}" block in ${join(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${overrideKey}" key instead (the ${toolTarget} target reads that block).`);
4889
+ if (overrideKey !== toolTarget && isRecord$1(json[toolTarget])) logger?.warn(`The "${toolTarget}" block in ${join(this.relativeDirPath, this.relativeFilePath)} is ignored. Author it under the "${overrideKey}" key instead (the ${toolTarget} target reads that block).`);
4831
4890
  const overrideBlock = json[overrideKey];
4832
- if (!isRecord(overrideBlock) || !isRecord(overrideBlock.permission)) return this;
4891
+ if (!isRecord$1(overrideBlock) || !isRecord$1(overrideBlock.permission)) return this;
4833
4892
  const { permission: toolScopedPermission, ...restOverride } = overrideBlock;
4834
4893
  const merged = {
4835
4894
  ...json,
@@ -5139,6 +5198,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5139
5198
  compatibility: z.optional(z.looseObject({})),
5140
5199
  metadata: z.optional(z.looseObject({}))
5141
5200
  })),
5201
+ kiro: z.optional(z.looseObject({
5202
+ license: z.optional(z.string()),
5203
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
5204
+ metadata: z.optional(z.looseObject({}))
5205
+ })),
5142
5206
  deepagents: z.optional(z.looseObject({
5143
5207
  "allowed-tools": z.optional(z.array(z.string())),
5144
5208
  license: z.optional(z.string()),
@@ -5147,7 +5211,11 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5147
5211
  })),
5148
5212
  copilot: z.optional(z.looseObject({
5149
5213
  license: z.optional(z.string()),
5150
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
5214
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
5215
+ "argument-hint": z.optional(z.string()),
5216
+ "user-invocable": z.optional(z.boolean()),
5217
+ "disable-model-invocation": z.optional(z.boolean()),
5218
+ context: z.optional(z.string())
5151
5219
  })),
5152
5220
  copilotcli: z.optional(z.looseObject({
5153
5221
  license: z.optional(z.string()),
@@ -5205,7 +5273,9 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5205
5273
  })),
5206
5274
  factorydroid: z.optional(z.looseObject({
5207
5275
  "disable-model-invocation": z.optional(z.boolean()),
5208
- "user-invocable": z.optional(z.boolean())
5276
+ "user-invocable": z.optional(z.boolean()),
5277
+ enabled: z.optional(z.boolean()),
5278
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
5209
5279
  })),
5210
5280
  grokcli: z.optional(z.looseObject({
5211
5281
  "disable-model-invocation": z.optional(z.boolean()),
@@ -5418,8 +5488,8 @@ async function getLocalSkillDirNames(outputRoot) {
5418
5488
  * Resolve the effective `disable-model-invocation` value for a tool skill.
5419
5489
  *
5420
5490
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
5421
- * default that applies to every tool supporting the flag (claudecode, cursor,
5422
- * zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5491
+ * default that applies to every tool supporting the flag (claudecode, copilot,
5492
+ * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
5423
5493
  * default with a per-target value. A defined section value (including `false`)
5424
5494
  * always wins over the root default.
5425
5495
  *
@@ -5432,8 +5502,8 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
5432
5502
  * Resolve the effective `user-invocable` value for a tool skill.
5433
5503
  *
5434
5504
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
5435
- * that applies to every tool supporting the flag (claudecode, qwencode, vibe,
5436
- * grokcli, factorydroid). Each tool's own section may override that default with a
5505
+ * that applies to every tool supporting the flag (claudecode, copilot,
5506
+ * copilotcli, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
5437
5507
  * per-target value. A defined section value (including `false`) always wins
5438
5508
  * over the root default.
5439
5509
  *
@@ -5646,6 +5716,31 @@ function fileContentsEquivalent({ filePath, expected, existing }) {
5646
5716
  if (structured !== void 0) return structured;
5647
5717
  return addTrailingNewline(expected) === addTrailingNewline(existing);
5648
5718
  }
5719
+ /**
5720
+ * Whether an on-disk companion file is equivalent to the generated one.
5721
+ *
5722
+ * Companion files (everything beside a skill's `SKILL.md`) are written byte for
5723
+ * byte, so byte equality is the whole test for a user asset carried through
5724
+ * from the source directory: a CRLF fixture or a deliberately newline-less file
5725
+ * must compare equal to itself and unequal to a normalized copy, and a copy
5726
+ * that has drifted must be repaired rather than tolerated.
5727
+ *
5728
+ * A `composed` file is different — Rulesync builds it from frontmatter (Codex
5729
+ * CLI's `agents/openai.yaml`), so differing bytes fall back to the structured
5730
+ * comparison and a formatter re-indenting it is not reported as a change on
5731
+ * every generate. Only the structured verdict counts: there is deliberately no
5732
+ * text fallback, since trailing-whitespace-insensitive text equality is exactly
5733
+ * the normalization companion files no longer get.
5734
+ */
5735
+ function companionFileContentsEquivalent({ filePath, expected, existing, composed = false }) {
5736
+ if (existing === null) return false;
5737
+ if (existing.equals(expected)) return true;
5738
+ if (!composed) return false;
5739
+ const expectedText = expected.toString("utf-8");
5740
+ const existingText = existing.toString("utf-8");
5741
+ if (!Buffer.from(expectedText, "utf-8").equals(expected) || !Buffer.from(existingText, "utf-8").equals(existing)) return false;
5742
+ return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
5743
+ }
5649
5744
  //#endregion
5650
5745
  //#region src/types/feature-processor.ts
5651
5746
  var FeatureProcessor = class {
@@ -7046,6 +7141,14 @@ const SHARED_CONFIG_OWNERSHIP = {
7046
7141
  ownedKeys: ["mcp", "tools"]
7047
7142
  } }
7048
7143
  },
7144
+ ".config/goose/config.yaml": {
7145
+ format: "yaml",
7146
+ invalidRootPolicy: "error",
7147
+ features: { mcp: {
7148
+ kind: "replace-owned-keys",
7149
+ ownedKeys: ["extensions"]
7150
+ } }
7151
+ },
7049
7152
  [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
7050
7153
  format: "toml",
7051
7154
  features: {
@@ -8194,7 +8297,7 @@ var AntigravitySharedCommand = class extends ToolCommand {
8194
8297
  }
8195
8298
  static extractAntigravityConfig(rulesyncCommand) {
8196
8299
  const antigravity = rulesyncCommand.getFrontmatter().antigravity;
8197
- return isRecord(antigravity) ? antigravity : void 0;
8300
+ return isRecord$1(antigravity) ? antigravity : void 0;
8198
8301
  }
8199
8302
  static resolveTrigger(rulesyncCommand, antigravityConfig) {
8200
8303
  const rulesyncFrontmatter = rulesyncCommand.getFrontmatter();
@@ -9284,9 +9387,10 @@ const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
9284
9387
  //#region src/features/commands/factorydroid-command.ts
9285
9388
  const FactorydroidCommandFrontmatterSchema = z.looseObject({
9286
9389
  description: z.optional(z.string()),
9287
- "argument-hint": z.optional(z.string()),
9288
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
9390
+ "argument-hint": z.optional(z.string())
9289
9391
  });
9392
+ /** Not a Droid command surface; see the schema comment above. */
9393
+ const FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS = ["allowed-tools"];
9290
9394
  var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9291
9395
  frontmatter;
9292
9396
  body;
@@ -9313,6 +9417,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9313
9417
  }
9314
9418
  toRulesyncCommand() {
9315
9419
  const { description, ...restFields } = this.frontmatter;
9420
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete restFields[field];
9316
9421
  const rulesyncFrontmatter = {
9317
9422
  targets: ["*"],
9318
9423
  description,
@@ -9336,6 +9441,7 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9336
9441
  description: rulesyncFrontmatter.description,
9337
9442
  ...factorydroidFields
9338
9443
  };
9444
+ for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) delete factorydroidFrontmatter[field];
9339
9445
  const body = rulesyncCommand.getBody();
9340
9446
  const paths = this.getSettablePaths({ global });
9341
9447
  return new FactorydroidCommand({
@@ -11981,8 +12087,8 @@ async function lookupPromptDescription({ outputRoot, relativeFilePath, name }) {
11981
12087
  }
11982
12088
  if (!isPlainObject$1(parsed) || !Array.isArray(parsed.prompts)) return "";
11983
12089
  const expectedContentFile = toPosixPath(join("prompts", relativeFilePath));
11984
- const entry = parsed.prompts.find((candidate) => isRecord(candidate) && (candidate.content_file === expectedContentFile || candidate.name === name));
11985
- return isRecord(entry) && typeof entry.description === "string" ? entry.description : "";
12090
+ const entry = parsed.prompts.find((candidate) => isRecord$1(candidate) && (candidate.content_file === expectedContentFile || candidate.name === name));
12091
+ return isRecord$1(entry) && typeof entry.description === "string" ? entry.description : "";
11986
12092
  }
11987
12093
  /**
11988
12094
  * The shared `.rovodev/prompts.yml` manifest that indexes every saved prompt.
@@ -13047,16 +13153,28 @@ function buildEffectiveHooks$1({ config, toolOverrideHooks, supportedEvents }) {
13047
13153
  };
13048
13154
  }
13049
13155
  /**
13050
- * Group a list of hook definitions by their `matcher` (empty string when absent),
13051
- * preserving insertion order of both keys and grouped definitions.
13156
+ * Group a list of hook definitions by their `matcher` (empty string when
13157
+ * absent), preserving insertion order of both keys and grouped definitions.
13158
+ * Definitions that disagree on a `subdividesGroup` passthrough field are split
13159
+ * into separate groups, so a restricting field is never inherited by a hook
13160
+ * that did not ask for it.
13052
13161
  */
13053
- function groupDefinitionsByMatcher(definitions) {
13162
+ function groupDefinitionsByMatcher({ definitions, converterConfig }) {
13163
+ const subdividingFields = (converterConfig.groupPassthroughFields ?? []).filter(({ subdividesGroup }) => subdividesGroup);
13054
13164
  const byMatcher = /* @__PURE__ */ new Map();
13055
13165
  for (const def of definitions) {
13056
- const key = def.matcher ?? "";
13057
- const list = byMatcher.get(key);
13058
- if (list) list.push(def);
13059
- else byMatcher.set(key, [def]);
13166
+ const rawMatcher = def.matcher ?? "";
13167
+ const matcher = converterConfig.wildcardMatcherMeansAll && rawMatcher === "*" ? "" : rawMatcher;
13168
+ const key = [matcher, ...subdividingFields.map(({ canonical, valueType }) => {
13169
+ const value = def[canonical];
13170
+ return isGroupPassthroughValue(value, valueType) ? stableJson(value) : "";
13171
+ })].join("\0");
13172
+ const group = byMatcher.get(key);
13173
+ if (group) group.defs.push(def);
13174
+ else byMatcher.set(key, {
13175
+ matcher,
13176
+ defs: [def]
13177
+ });
13060
13178
  }
13061
13179
  return byMatcher;
13062
13180
  }
@@ -13083,59 +13201,148 @@ function applyCommandPrefix({ def, converterConfig }) {
13083
13201
  return `"${converterConfig.projectDirVar}"/${relativeCommand}`;
13084
13202
  }
13085
13203
  /**
13086
- * Emit the configured boolean passthrough fields on the tool side, mapping each
13087
- * canonical field name to its (possibly renamed) tool field name. Only boolean
13088
- * values are carried through.
13204
+ * Whether a field registered for `command` hooks only applies to this hook.
13205
+ * Applied on both export and import: a value imported into a canonical field
13206
+ * the exporter would then drop is silently deleted on the next generate.
13089
13207
  */
13090
- function emitBooleanPassthroughFields({ def, hookType, converterConfig }) {
13091
- return Object.fromEntries((converterConfig.booleanPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13092
- if (commandOnly === true && hookType !== "command") return false;
13093
- return typeof def[canonical] === "boolean";
13094
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13208
+ function isFieldApplicable({ commandOnly, hookType }) {
13209
+ return commandOnly !== true || hookType === "command";
13095
13210
  }
13096
13211
  /**
13097
- * Import the configured boolean passthrough fields back into canonical fields,
13098
- * reversing {@link emitBooleanPassthroughFields}. Only boolean values are read.
13099
- */
13100
- function importBooleanPassthroughFields({ h, converterConfig }) {
13101
- return Object.fromEntries((converterConfig.booleanPassthroughFields ?? []).filter(({ tool }) => typeof h[tool] === "boolean").map(({ canonical, tool }) => [canonical, h[tool]]));
13102
- }
13103
- /**
13104
- * Emit the configured string passthrough fields on the tool side, mapping each
13105
- * canonical field name to its (possibly renamed) tool field name. Only non-empty
13106
- * string values are carried through.
13107
- */
13108
- function emitStringPassthroughFields({ def, hookType, converterConfig }) {
13109
- return Object.fromEntries((converterConfig.stringPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13110
- if (commandOnly === true && hookType !== "command") return false;
13111
- return typeof def[canonical] === "string" && def[canonical] !== "";
13112
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13113
- }
13114
- /**
13115
- * Import the configured string passthrough fields back into canonical fields,
13116
- * reversing {@link emitStringPassthroughFields}. Only non-empty string values
13117
- * are read.
13212
+ * Emit the configured passthrough fields on the tool side, mapping each
13213
+ * canonical field name to its (possibly renamed) tool field name. Only values
13214
+ * accepted by `isValid` are carried through, so a malformed field can't leak
13215
+ * into a config the tool would reject.
13216
+ *
13217
+ * A field the tool documents on `command` hooks only is dropped when authored
13218
+ * on another hook type. That is a value the user hand-wrote in
13219
+ * `.rulesync/hooks.*` and the canonical schema does not cross-validate it
13220
+ * against the hook's type, so it is warned about rather than deleted in
13221
+ * silence the mirror of the import side.
13118
13222
  */
13119
- function importStringPassthroughFields({ h, converterConfig }) {
13120
- return Object.fromEntries((converterConfig.stringPassthroughFields ?? []).filter(({ tool }) => typeof h[tool] === "string" && h[tool] !== "").map(({ canonical, tool }) => [canonical, h[tool]]));
13121
- }
13223
+ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn }) {
13224
+ for (const { canonical, tool, commandOnly } of fields) {
13225
+ const value = def[canonical];
13226
+ if (value === void 0) continue;
13227
+ if (!isFieldApplicable({
13228
+ commandOnly,
13229
+ hookType
13230
+ })) {
13231
+ warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": this tool documents "${tool}" on "command" hooks only, so it is not generated.`);
13232
+ continue;
13233
+ }
13234
+ if (!isValid({
13235
+ value,
13236
+ canonical
13237
+ })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${JSON.stringify(value)} is not a value this tool can express as "${tool}".`);
13238
+ }
13239
+ return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
13240
+ commandOnly,
13241
+ hookType
13242
+ }) && isValid({
13243
+ value: def[canonical],
13244
+ canonical
13245
+ })).map(({ canonical, tool }) => [tool, def[canonical]]));
13246
+ }
13247
+ /**
13248
+ * Import the configured passthrough fields back into canonical fields,
13249
+ * reversing {@link emitPassthroughFields}. A field the tool documents on
13250
+ * `command` hooks only is skipped here too, and `describeInvalid` — when the
13251
+ * kind has a rule an authored file can plausibly violate — turns a rejected
13252
+ * value into a warning instead of a silent drop.
13253
+ */
13254
+ function importPassthroughFields({ h, hookType, fields, isValid, describeInvalid, warn }) {
13255
+ const applicable = fields.filter(({ commandOnly }) => isFieldApplicable({
13256
+ commandOnly,
13257
+ hookType
13258
+ }));
13259
+ const skipped = fields.filter(({ commandOnly }) => !isFieldApplicable({
13260
+ commandOnly,
13261
+ hookType
13262
+ }));
13263
+ 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.`);
13264
+ for (const { tool, canonical } of applicable) if (describeInvalid !== void 0 && h[tool] !== void 0 && !isValid({
13265
+ value: h[tool],
13266
+ canonical
13267
+ })) warn?.(describeInvalid({
13268
+ tool,
13269
+ canonical,
13270
+ value: h[tool]
13271
+ }));
13272
+ return Object.fromEntries(applicable.filter(({ tool, canonical }) => isValid({
13273
+ value: h[tool],
13274
+ canonical
13275
+ })).map(({ canonical, tool }) => [canonical, h[tool]]));
13276
+ }
13277
+ const isBooleanValue = ({ value }) => typeof value === "boolean";
13278
+ const isFiniteNumber = ({ value }) => Number.isFinite(value);
13279
+ const isNonEmptyString = (value) => typeof value === "string" && value !== "";
13280
+ const isEmittableString = ({ value }) => isNonEmptyString(value);
13281
+ const isEmittableArray = ({ value }) => isStringArray(value);
13282
+ const isEmittableRecord = ({ value }) => isSafeStringRecord(value);
13283
+ const isImportableArray = ({ value }) => isSafeStringArray(value);
13284
+ /**
13285
+ * The canonical schema of one hook field, looked up by name. Read off the
13286
+ * schema's own shape rather than by parsing a one-field object, so a `canonical`
13287
+ * name that no longer exists resolves to `undefined` (a `looseObject` would
13288
+ * accept an unknown key and silently validate nothing) and a typo is caught by
13289
+ * the tests instead of quietly disabling the check.
13290
+ */
13291
+ const CANONICAL_FIELD_SCHEMAS = HookDefinitionSchema.def.shape;
13292
+ /**
13293
+ * Whether a value satisfies the constraints the canonical schema puts on the
13294
+ * field it would be imported into.
13295
+ *
13296
+ * Import needs this on top of the kind's shape check: a hand-written tool
13297
+ * settings file can hold `"shell": "zsh"`, which is a non-empty string but not
13298
+ * a member of the canonical `z.enum(["bash", "powershell"])`. Letting it in
13299
+ * would write a `.rulesync/hooks.jsonc` that fails validation on the *next*
13300
+ * run, taking the whole hooks feature down with it.
13301
+ */
13302
+ function satisfiesCanonicalField({ value, canonical }) {
13303
+ const schema = canonicalFieldSchema(canonical);
13304
+ return schema !== void 0 && z.safeParse(schema, value).success;
13305
+ }
13306
+ /** Own properties only, so a name like `toString` resolves to nothing. */
13307
+ function canonicalFieldSchema(canonical) {
13308
+ return Object.hasOwn(CANONICAL_FIELD_SCHEMAS, canonical) ? CANONICAL_FIELD_SCHEMAS[canonical] : void 0;
13309
+ }
13310
+ const isImportableString = ({ value, canonical }) => isNonEmptyString(value) && satisfiesCanonicalField({
13311
+ value,
13312
+ canonical
13313
+ });
13314
+ const isImportableNumber = ({ value, canonical }) => Number.isFinite(value) && satisfiesCanonicalField({
13315
+ value,
13316
+ canonical
13317
+ });
13122
13318
  /**
13123
- * Emit the configured string-array passthrough fields on the tool side.
13319
+ * Say which rule the value broke, so the warning names the actual constraint
13320
+ * rather than asserting a canonical rejection that may not be the reason. A
13321
+ * closed enum lists its members; a rule carrying its own message (the
13322
+ * control-character check behind `safeString`) reuses it.
13124
13323
  */
13125
- function emitArrayPassthroughFields({ def, hookType, converterConfig }) {
13126
- return Object.fromEntries((converterConfig.arrayPassthroughFields ?? []).filter(({ canonical, commandOnly }) => {
13127
- if (commandOnly === true && hookType !== "command") return false;
13128
- return isStringArray(def[canonical]);
13129
- }).map(({ canonical, tool }) => [tool, def[canonical]]));
13324
+ function describeScalarConstraint({ canonical, value }) {
13325
+ const schema = canonicalFieldSchema(canonical);
13326
+ const result = schema === void 0 ? void 0 : z.safeParse(schema, value);
13327
+ if (result === void 0 || result.success) return `it is not a value this field carries through.`;
13328
+ const issue = result.error.issues[0];
13329
+ if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
13330
+ return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
13130
13331
  }
13332
+ const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${JSON.stringify(value)}) while importing a hook: ${describeScalarConstraint({
13333
+ canonical,
13334
+ value
13335
+ })} Importing it would fail validation on the next run.`;
13336
+ const describeInvalidArray = ({ tool }) => `Dropping "${tool}" while importing a hook: it must be a list of strings without newline, carriage return or NUL characters.`;
13337
+ 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.`;
13131
13338
  /**
13132
- * Import the configured string-array passthrough fields, reversing
13133
- * {@link emitArrayPassthroughFields}.
13339
+ * Check a value against the shape its field documents. A string field also
13340
+ * rejects control characters, matching the canonical `safeString` so an
13341
+ * imported value cannot fail validation on the next generate.
13134
13342
  */
13135
- function importArrayPassthroughFields({ h, converterConfig, logger }) {
13136
- const fields = converterConfig.arrayPassthroughFields ?? [];
13137
- 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.`);
13138
- return Object.fromEntries(fields.filter(({ tool }) => isSafeStringArray(h[tool])).map(({ canonical, tool }) => [canonical, h[tool]]));
13343
+ function isGroupPassthroughValue(value, valueType = "object") {
13344
+ if (valueType === "string") return typeof value === "string" && !CONTROL_CHARS.some((char) => value.includes(char));
13345
+ return isPlainObject$1(value);
13139
13346
  }
13140
13347
  /**
13141
13348
  * Emit the configured group-level passthrough fields, taken from the first
@@ -13143,12 +13350,12 @@ function importArrayPassthroughFields({ h, converterConfig, logger }) {
13143
13350
  */
13144
13351
  function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }) {
13145
13352
  const emitted = {};
13146
- for (const { canonical, tool } of converterConfig.groupPassthroughFields ?? []) {
13353
+ for (const { canonical, tool, valueType } of converterConfig.groupPassthroughFields ?? []) {
13147
13354
  const carried = defs.map((def) => def[canonical]);
13148
- const first = carried.find((value) => isPlainObject$1(value));
13355
+ const first = carried.find((value) => isGroupPassthroughValue(value, valueType));
13149
13356
  if (first === void 0) continue;
13150
13357
  const firstStable = stableJson(first);
13151
- const agrees = (value) => isPlainObject$1(value) && stableJson(value) === firstStable;
13358
+ const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
13152
13359
  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.`);
13153
13360
  emitted[tool] = first;
13154
13361
  }
@@ -13160,7 +13367,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
13160
13367
  */
13161
13368
  function importGroupPassthroughFields({ rawEntry, converterConfig }) {
13162
13369
  const entry = rawEntry;
13163
- return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool }) => isPlainObject$1(entry[tool])).map(({ canonical, tool }) => [canonical, entry[tool]]));
13370
+ return Object.fromEntries((converterConfig.groupPassthroughFields ?? []).filter(({ tool, valueType }) => isGroupPassthroughValue(entry[tool], valueType)).map(({ canonical, tool }) => [canonical, entry[tool]]));
13164
13371
  }
13165
13372
  /**
13166
13373
  * Emit the payload fields specific to a hook type — `url`/`headers`/
@@ -13188,7 +13395,54 @@ function emitTypePayloadFields({ def, hookType, converterConfig }) {
13188
13395
  function isSupportedHookType({ type, converterConfig }) {
13189
13396
  return converterConfig.supportedHookTypes?.has(type ?? "command") ?? true;
13190
13397
  }
13191
- function buildToolHooks({ defs, converterConfig }) {
13398
+ /**
13399
+ * Emit every per-hook passthrough kind for one canonical definition.
13400
+ */
13401
+ function emitAllPassthroughFields({ def, hookType, eventName, converterConfig, warn }) {
13402
+ return {
13403
+ ...emitPassthroughFields({
13404
+ def,
13405
+ hookType,
13406
+ eventName,
13407
+ fields: converterConfig.booleanPassthroughFields ?? [],
13408
+ isValid: isBooleanValue,
13409
+ warn
13410
+ }),
13411
+ ...emitPassthroughFields({
13412
+ def,
13413
+ hookType,
13414
+ eventName,
13415
+ fields: converterConfig.numberPassthroughFields ?? [],
13416
+ isValid: isFiniteNumber,
13417
+ warn
13418
+ }),
13419
+ ...emitPassthroughFields({
13420
+ def,
13421
+ hookType,
13422
+ eventName,
13423
+ fields: converterConfig.stringPassthroughFields ?? [],
13424
+ isValid: isEmittableString,
13425
+ warn
13426
+ }),
13427
+ ...emitPassthroughFields({
13428
+ def,
13429
+ hookType,
13430
+ eventName,
13431
+ fields: converterConfig.arrayPassthroughFields ?? [],
13432
+ isValid: isEmittableArray,
13433
+ warn
13434
+ }),
13435
+ ...emitPassthroughFields({
13436
+ def,
13437
+ hookType,
13438
+ eventName,
13439
+ fields: converterConfig.recordPassthroughFields ?? [],
13440
+ isValid: isEmittableRecord,
13441
+ warn
13442
+ })
13443
+ };
13444
+ }
13445
+ function buildToolHooks({ defs, eventName, converterConfig, warn }) {
13192
13446
  const hooks = [];
13193
13447
  for (const def of defs) {
13194
13448
  const hookType = def.type ?? "command";
@@ -13201,20 +13455,12 @@ function buildToolHooks({ defs, converterConfig }) {
13201
13455
  converterConfig
13202
13456
  });
13203
13457
  hooks.push({
13204
- ...emitBooleanPassthroughFields({
13458
+ ...emitAllPassthroughFields({
13205
13459
  def,
13206
13460
  hookType,
13207
- converterConfig
13208
- }),
13209
- ...emitStringPassthroughFields({
13210
- def,
13211
- hookType,
13212
- converterConfig
13213
- }),
13214
- ...emitArrayPassthroughFields({
13215
- def,
13216
- hookType,
13217
- converterConfig
13461
+ eventName,
13462
+ converterConfig,
13463
+ warn
13218
13464
  }),
13219
13465
  type: hookType,
13220
13466
  ...command !== void 0 && command !== null && { command },
@@ -13232,6 +13478,17 @@ function buildToolHooks({ defs, converterConfig }) {
13232
13478
  return hooks;
13233
13479
  }
13234
13480
  /**
13481
+ * A `warn` that says each distinct thing once per conversion.
13482
+ */
13483
+ function warnOnce(logger) {
13484
+ const seen = /* @__PURE__ */ new Set();
13485
+ return (message) => {
13486
+ if (seen.has(message)) return;
13487
+ seen.add(message);
13488
+ logger?.warn(message);
13489
+ };
13490
+ }
13491
+ /**
13235
13492
  * Convert canonical hooks config to tool-specific format (shared by Claude and Factory Droid).
13236
13493
  * Uses explicit event name mapping tables rather than algorithmic case conversion,
13237
13494
  * since tool event names may differ entirely from canonical names
@@ -13243,17 +13500,23 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
13243
13500
  toolOverrideHooks,
13244
13501
  supportedEvents: converterConfig.supportedEvents
13245
13502
  });
13503
+ const warn = warnOnce(logger);
13246
13504
  const result = {};
13247
13505
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
13248
13506
  const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
13249
- const byMatcher = groupDefinitionsByMatcher(definitions);
13507
+ const byMatcher = groupDefinitionsByMatcher({
13508
+ definitions,
13509
+ converterConfig
13510
+ });
13250
13511
  const entries = [];
13251
13512
  const isNoMatcherEvent = converterConfig.noMatcherEvents?.has(eventName) ?? false;
13252
- for (const [matcherKey, defs] of byMatcher) {
13513
+ for (const { matcher: matcherKey, defs } of byMatcher.values()) {
13253
13514
  if (isNoMatcherEvent && matcherKey) logger?.warn(`matcher "${matcherKey}" on "${eventName}" hook will be ignored — this event does not support matchers`);
13254
13515
  const hooks = buildToolHooks({
13255
13516
  defs,
13256
- converterConfig
13517
+ eventName,
13518
+ converterConfig,
13519
+ warn
13257
13520
  });
13258
13521
  if (hooks.length === 0) continue;
13259
13522
  const includeMatcher = matcherKey && !isNoMatcherEvent;
@@ -13329,9 +13592,27 @@ function isStringArray(value) {
13329
13592
  }
13330
13593
  /** Compare object values without letting key order decide the answer. */
13331
13594
  function stableJson(value) {
13595
+ if (typeof value === "string") return JSON.stringify(value);
13332
13596
  return JSON.stringify(Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b))));
13333
13597
  }
13334
13598
  /**
13599
+ * A string map safe to hand a tool as a hook's environment block. On top of
13600
+ * {@link isStringRecord} it rejects a non-plain object (a class instance is not
13601
+ * data) and applies the control-character rule to the values, as
13602
+ * {@link isSafeStringArray} does for `args`.
13603
+ *
13604
+ * The keys are checked more strictly than the values. A tool builds each entry
13605
+ * back into a `KEY=VALUE` string for the spawned process, so a key holding `=`
13606
+ * (or a control character, or nothing at all) names a different variable than
13607
+ * it appears to — `PATH=/tmp/evil` written as a key would set `PATH`. An
13608
+ * authored `.rulesync/hooks.*` can arrive via `rulesync fetch`, so that is not
13609
+ * a shape to pass along.
13610
+ */
13611
+ function isSafeStringRecord(value) {
13612
+ if (!isPlainObject$1(value) || !isStringRecord(value)) return false;
13613
+ return Object.entries(value).every(([key, entry]) => key !== "" && !key.includes("=") && !CONTROL_CHARS.some((char) => key.includes(char) || entry.includes(char)));
13614
+ }
13615
+ /**
13335
13616
  * Control characters cannot ride from an existing tool config into a canonical
13336
13617
  * field the schema guards with `safeString`, or the next generate fails
13337
13618
  * validation on a file this import itself wrote — and the hooks feature is
@@ -13341,34 +13622,152 @@ function isSafeStringArray(value) {
13341
13622
  return isStringArray(value) && value.every((entry) => !CONTROL_CHARS.some((char) => entry.includes(char)));
13342
13623
  }
13343
13624
  /**
13625
+ * A raw string kept only if the canonical field it would land in accepts it,
13626
+ * warning when it does not. The canonical string fields are guarded by
13627
+ * `safeString`, so an existing tool config carrying a control character would
13628
+ * otherwise be imported into a file the next generate refuses to read.
13629
+ */
13630
+ function importCanonicalString({ value, canonical, warn }) {
13631
+ if (typeof value !== "string") return;
13632
+ if (satisfiesCanonicalField({
13633
+ value,
13634
+ canonical
13635
+ })) return value;
13636
+ warn?.(describeInvalidScalar({
13637
+ tool: canonical,
13638
+ canonical,
13639
+ value
13640
+ }));
13641
+ }
13642
+ /**
13344
13643
  * Import the payload fields specific to a hook type, type-checking each raw
13345
13644
  * value before it enters the canonical definition.
13346
13645
  */
13347
- function importTypePayloadFields({ h, hookType }) {
13348
- if (hookType === "http") return {
13349
- ...typeof h.url === "string" && { url: h.url },
13350
- ...isStringRecord(h.headers) && { headers: h.headers },
13351
- ...isStringArray(h.allowedEnvVars) && { allowedEnvVars: h.allowedEnvVars }
13352
- };
13353
- if (hookType === "mcp_tool") return {
13354
- ...typeof h.server === "string" && { server: h.server },
13355
- ...typeof h.tool === "string" && { tool: h.tool },
13356
- ...h.input !== null && typeof h.input === "object" && !Array.isArray(h.input) && { input: h.input }
13357
- };
13358
- if (hookType === "prompt" || hookType === "agent") return typeof h.model === "string" ? { model: h.model } : {};
13646
+ function importTypePayloadFields({ h, hookType, warn }) {
13647
+ if (hookType === "http") {
13648
+ const url = importCanonicalString({
13649
+ value: h.url,
13650
+ canonical: "url",
13651
+ warn
13652
+ });
13653
+ const headers = isStringRecord(h.headers) && !satisfiesCanonicalField({
13654
+ value: h.headers,
13655
+ canonical: "headers"
13656
+ }) ? (warn?.(describeInvalidScalar({
13657
+ tool: "headers",
13658
+ canonical: "headers",
13659
+ value: h.headers
13660
+ })), void 0) : h.headers;
13661
+ return {
13662
+ ...url !== void 0 && { url },
13663
+ ...isStringRecord(headers) && { headers },
13664
+ ...isStringArray(h.allowedEnvVars) && { allowedEnvVars: h.allowedEnvVars }
13665
+ };
13666
+ }
13667
+ if (hookType === "mcp_tool") {
13668
+ const server = importCanonicalString({
13669
+ value: h.server,
13670
+ canonical: "server",
13671
+ warn
13672
+ });
13673
+ const tool = importCanonicalString({
13674
+ value: h.tool,
13675
+ canonical: "tool",
13676
+ warn
13677
+ });
13678
+ return {
13679
+ ...server !== void 0 && { server },
13680
+ ...tool !== void 0 && { tool },
13681
+ ...h.input !== null && typeof h.input === "object" && !Array.isArray(h.input) && { input: h.input }
13682
+ };
13683
+ }
13684
+ if (hookType === "prompt" || hookType === "agent") {
13685
+ const model = importCanonicalString({
13686
+ value: h.model,
13687
+ canonical: "model",
13688
+ warn
13689
+ });
13690
+ return model !== void 0 ? { model } : {};
13691
+ }
13359
13692
  return {};
13360
13693
  }
13361
13694
  /**
13695
+ * Import every per-hook passthrough kind for one tool hook record, reversing
13696
+ * the emit side in {@link buildToolHooks}.
13697
+ */
13698
+ function importAllPassthroughFields({ h, hookType, converterConfig, warn }) {
13699
+ return {
13700
+ ...importPassthroughFields({
13701
+ h,
13702
+ hookType,
13703
+ fields: converterConfig.booleanPassthroughFields ?? [],
13704
+ isValid: isBooleanValue,
13705
+ warn
13706
+ }),
13707
+ ...importPassthroughFields({
13708
+ h,
13709
+ hookType,
13710
+ fields: converterConfig.numberPassthroughFields ?? [],
13711
+ isValid: isImportableNumber,
13712
+ describeInvalid: describeInvalidScalar,
13713
+ warn
13714
+ }),
13715
+ ...importPassthroughFields({
13716
+ h,
13717
+ hookType,
13718
+ fields: converterConfig.stringPassthroughFields ?? [],
13719
+ isValid: isImportableString,
13720
+ describeInvalid: describeInvalidScalar,
13721
+ warn
13722
+ }),
13723
+ ...importPassthroughFields({
13724
+ h,
13725
+ hookType,
13726
+ fields: converterConfig.arrayPassthroughFields ?? [],
13727
+ isValid: isImportableArray,
13728
+ describeInvalid: describeInvalidArray,
13729
+ warn
13730
+ }),
13731
+ ...importPassthroughFields({
13732
+ h,
13733
+ hookType,
13734
+ fields: converterConfig.recordPassthroughFields ?? [],
13735
+ isValid: isEmittableRecord,
13736
+ describeInvalid: describeInvalidRecord,
13737
+ warn
13738
+ })
13739
+ };
13740
+ }
13741
+ /**
13362
13742
  * Convert a single tool hook record into a canonical hook definition.
13363
13743
  */
13364
- function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13365
- const command = stripCommandPrefix({
13366
- command: h.command,
13367
- converterConfig
13368
- });
13744
+ function toolHookToCanonical({ h, rawEntry, converterConfig, warn }) {
13369
13745
  const hookType = isImportedHookType(h.type) ? h.type : "command";
13746
+ const command = importCanonicalString({
13747
+ value: stripCommandPrefix({
13748
+ command: h.command,
13749
+ converterConfig
13750
+ }),
13751
+ canonical: "command",
13752
+ warn
13753
+ });
13370
13754
  const timeout = typeof h.timeout === "number" ? h.timeout : void 0;
13371
- const prompt = typeof h.prompt === "string" ? h.prompt : void 0;
13755
+ const prompt = importCanonicalString({
13756
+ value: h.prompt,
13757
+ canonical: "prompt",
13758
+ warn
13759
+ });
13760
+ const name = importCanonicalString({
13761
+ value: h.name,
13762
+ canonical: "name",
13763
+ warn
13764
+ });
13765
+ const description = importCanonicalString({
13766
+ value: h.description,
13767
+ canonical: "description",
13768
+ warn
13769
+ });
13770
+ const matcher = rawEntry.matcher;
13372
13771
  return {
13373
13772
  type: hookType,
13374
13773
  ...command !== void 0 && command !== null && { command },
@@ -13376,40 +13775,130 @@ function toolHookToCanonical({ h, rawEntry, converterConfig, logger }) {
13376
13775
  ...prompt !== void 0 && prompt !== null && { prompt },
13377
13776
  ...importTypePayloadFields({
13378
13777
  h,
13379
- hookType
13380
- }),
13381
- ...converterConfig.passthroughFields?.includes("name") && typeof h.name === "string" && { name: h.name },
13382
- ...converterConfig.passthroughFields?.includes("description") && typeof h.description === "string" && { description: h.description },
13383
- ...importBooleanPassthroughFields({
13384
- h,
13385
- converterConfig
13778
+ hookType,
13779
+ warn
13386
13780
  }),
13387
- ...importStringPassthroughFields({
13388
- h,
13389
- converterConfig
13390
- }),
13391
- ...importArrayPassthroughFields({
13781
+ ...converterConfig.passthroughFields?.includes("name") && name !== void 0 && { name },
13782
+ ...converterConfig.passthroughFields?.includes("description") && description !== void 0 && { description },
13783
+ ...importAllPassthroughFields({
13392
13784
  h,
13785
+ hookType,
13393
13786
  converterConfig,
13394
- logger
13787
+ warn
13395
13788
  }),
13396
13789
  ...importGroupPassthroughFields({
13397
13790
  rawEntry,
13398
13791
  converterConfig
13399
13792
  }),
13400
- ...rawEntry.matcher !== void 0 && rawEntry.matcher !== null && rawEntry.matcher !== "" && { matcher: rawEntry.matcher }
13793
+ ...matcher !== void 0 && matcher !== null && matcher !== "" && { matcher }
13401
13794
  };
13402
13795
  }
13403
13796
  /**
13404
- * Convert a single tool matcher entry into canonical hook definitions.
13797
+ * The fields whose value decides what a hook *is*, listed per hook type. When
13798
+ * one of them cannot be imported, dropping just the field would leave
13799
+ * something worse than nothing: a hook that loses its body runs nothing, and
13800
+ * one that loses its `matcher` fires on *everything* — a silent widening of
13801
+ * what the imported rule does. So the whole definition is skipped instead,
13802
+ * with the reason named. A field that does not define *this* type (a `prompt`
13803
+ * left on a command hook) is not one of them: it is dropped on its own, the
13804
+ * way any other unusable field is.
13805
+ */
13806
+ function definingFields({ h, rawEntry, hookType, converterConfig }) {
13807
+ const fields = [{
13808
+ field: "matcher",
13809
+ value: rawEntry.matcher
13810
+ }];
13811
+ if (hookType === "command") fields.push({
13812
+ field: "command",
13813
+ value: typeof h.command === "string" ? stripCommandPrefix({
13814
+ command: h.command,
13815
+ converterConfig
13816
+ }) : h.command
13817
+ });
13818
+ if (hookType === "prompt" || hookType === "agent") fields.push({
13819
+ field: "prompt",
13820
+ value: h.prompt
13821
+ });
13822
+ if (hookType === "http") fields.push({
13823
+ field: "url",
13824
+ value: h.url
13825
+ });
13826
+ if (hookType === "mcp_tool") fields.push({
13827
+ field: "server",
13828
+ value: h.server
13829
+ }, {
13830
+ field: "tool",
13831
+ value: h.tool
13832
+ });
13833
+ return fields;
13834
+ }
13835
+ /**
13836
+ * Why no hook of this matcher group can be imported, or `undefined` when they
13837
+ * can. A group field that *restricts* when its hooks run (`subdividesGroup`)
13838
+ * is the group-level twin of `matcher`: importing the group without it would
13839
+ * widen every hook in it, so the group is skipped instead.
13405
13840
  */
13406
- function toolMatcherEntryToCanonical({ rawEntry, converterConfig, logger }) {
13407
- return (rawEntry.hooks ?? []).map((h) => toolHookToCanonical({
13841
+ function describeGroupSkipReason({ rawEntry, converterConfig }) {
13842
+ const entry = rawEntry;
13843
+ for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
13844
+ const value = entry[tool];
13845
+ if (subdividesGroup !== true || value === void 0) continue;
13846
+ 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.`;
13847
+ }
13848
+ }
13849
+ /**
13850
+ * Why this hook cannot be imported, or `undefined` when it can.
13851
+ */
13852
+ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
13853
+ for (const { field, value } of definingFields({
13408
13854
  h,
13409
13855
  rawEntry,
13410
- converterConfig,
13411
- logger
13412
- }));
13856
+ hookType,
13857
+ converterConfig
13858
+ })) {
13859
+ if (value === void 0 || satisfiesCanonicalField({
13860
+ value,
13861
+ canonical: field
13862
+ })) continue;
13863
+ return `Skipping a hook while importing: its "${field}" (${JSON.stringify(value)}) is unusable — ${describeScalarConstraint({
13864
+ canonical: field,
13865
+ value
13866
+ })} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
13867
+ }
13868
+ }
13869
+ /**
13870
+ * Convert a single tool matcher entry into canonical hook definitions.
13871
+ */
13872
+ function toolMatcherEntryToCanonical({ rawEntry, converterConfig, warn }) {
13873
+ const hookDefs = rawEntry.hooks ?? [];
13874
+ const groupSkipReason = describeGroupSkipReason({
13875
+ rawEntry,
13876
+ converterConfig
13877
+ });
13878
+ if (groupSkipReason !== void 0) {
13879
+ warn?.(groupSkipReason);
13880
+ return [];
13881
+ }
13882
+ const definitions = [];
13883
+ for (const h of hookDefs) {
13884
+ const skipReason = describeHookSkipReason({
13885
+ h,
13886
+ rawEntry,
13887
+ hookType: isImportedHookType(h.type) ? h.type : "command",
13888
+ converterConfig
13889
+ });
13890
+ if (skipReason !== void 0) {
13891
+ warn?.(skipReason);
13892
+ continue;
13893
+ }
13894
+ definitions.push(toolHookToCanonical({
13895
+ h,
13896
+ rawEntry,
13897
+ converterConfig,
13898
+ warn
13899
+ }));
13900
+ }
13901
+ return definitions;
13413
13902
  }
13414
13903
  /**
13415
13904
  * Assemble the canonical hooks config a tool importer writes to
@@ -13439,6 +13928,7 @@ function buildImportedHooksConfig({ hooks, overrideKey, version = 1, extraOverri
13439
13928
  }
13440
13929
  function toolHooksToCanonical({ hooks, converterConfig, logger }) {
13441
13930
  if (hooks === null || hooks === void 0 || typeof hooks !== "object") return {};
13931
+ const warn = warnOnce(logger);
13442
13932
  const canonical = {};
13443
13933
  for (const [toolEventName, matcherEntries] of Object.entries(hooks)) {
13444
13934
  const eventName = converterConfig.toolToCanonicalEventNames[toolEventName] ?? toolEventName;
@@ -13449,7 +13939,7 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
13449
13939
  defs.push(...toolMatcherEntryToCanonical({
13450
13940
  rawEntry,
13451
13941
  converterConfig,
13452
- logger
13942
+ warn
13453
13943
  }));
13454
13944
  }
13455
13945
  if (defs.length > 0) canonical[eventName] = defs;
@@ -13488,7 +13978,9 @@ const ANTIGRAVITY_HOOK_NAME = "rulesync";
13488
13978
  * map for import. Accepts both the documented named-hook shape
13489
13979
  * (`{ "<name>": { "<Event>": [...], "enabled"?: bool } }`) and a legacy flat
13490
13980
  * shape (`{ "<Event>": [...] }`) so older or hand-written files still import.
13491
- * The per-hook `enabled` flag is ignored (canonical hooks have no equivalent).
13981
+ * The per-hook `enabled` flag is ignored. The canonical `enabled` field is a
13982
+ * property of a single hook definition, whereas Antigravity's flag gates a whole
13983
+ * named group, so the two do not map onto each other.
13492
13984
  */
13493
13985
  function flattenAntigravityHooks(parsed) {
13494
13986
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -13570,7 +14062,7 @@ var AntigravityHooks = class extends ToolHooks {
13570
14062
  validate
13571
14063
  });
13572
14064
  }
13573
- toRulesyncHooks() {
14065
+ toRulesyncHooks({ logger } = {}) {
13574
14066
  let parsed;
13575
14067
  try {
13576
14068
  parsed = JSON.parse(this.getFileContent());
@@ -13579,7 +14071,8 @@ var AntigravityHooks = class extends ToolHooks {
13579
14071
  }
13580
14072
  const hooks = toolHooksToCanonical({
13581
14073
  hooks: flattenAntigravityHooks(parsed),
13582
- converterConfig: ANTIGRAVITY_CONVERTER_CONFIG
14074
+ converterConfig: ANTIGRAVITY_CONVERTER_CONFIG,
14075
+ logger
13583
14076
  });
13584
14077
  const overrideKey = this.constructor.getOverrideKey();
13585
14078
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
@@ -13910,6 +14403,14 @@ var ClaudecodeHooks = class extends ToolHooks {
13910
14403
  isDeletable() {
13911
14404
  return false;
13912
14405
  }
14406
+ /**
14407
+ * The converter config used for both directions. Exposed as a static hook so
14408
+ * plugin-scoped subclasses can swap tool-specific details (e.g. the project
14409
+ * directory variable) without duplicating the rest of the config.
14410
+ */
14411
+ static getConverterConfig() {
14412
+ return CLAUDE_CONVERTER_CONFIG;
14413
+ }
13913
14414
  static getSettablePaths(_options = {}) {
13914
14415
  return {
13915
14416
  relativeDirPath: CLAUDECODE_DIR,
@@ -13935,7 +14436,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13935
14436
  const claudeHooks = canonicalToToolHooks({
13936
14437
  config,
13937
14438
  toolOverrideHooks: config.claudecode?.hooks,
13938
- converterConfig: CLAUDE_CONVERTER_CONFIG,
14439
+ converterConfig: this.getConverterConfig(),
13939
14440
  logger
13940
14441
  });
13941
14442
  const fileContent = applySharedConfigPatch({
@@ -13953,7 +14454,7 @@ var ClaudecodeHooks = class extends ToolHooks {
13953
14454
  validate
13954
14455
  });
13955
14456
  }
13956
- toRulesyncHooks() {
14457
+ toRulesyncHooks({ logger } = {}) {
13957
14458
  let settings;
13958
14459
  try {
13959
14460
  settings = JSON.parse(this.getFileContent());
@@ -13962,7 +14463,8 @@ var ClaudecodeHooks = class extends ToolHooks {
13962
14463
  }
13963
14464
  const hooks = toolHooksToCanonical({
13964
14465
  hooks: settings.hooks,
13965
- converterConfig: CLAUDE_CONVERTER_CONFIG
14466
+ converterConfig: this.constructor.getConverterConfig(),
14467
+ logger
13966
14468
  });
13967
14469
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
13968
14470
  hooks,
@@ -13991,6 +14493,21 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
13991
14493
  isDeletable() {
13992
14494
  return true;
13993
14495
  }
14496
+ /**
14497
+ * Plugin hook scripts ship inside the plugin, so their commands must resolve
14498
+ * against the plugin install directory rather than the consumer's project
14499
+ * root. Upstream documents `"${CLAUDE_PLUGIN_ROOT}"/scripts/format-code.sh`;
14500
+ * `$CLAUDE_PROJECT_DIR` would expand to a path in the consumer's own repo,
14501
+ * where the bundled script does not exist.
14502
+ *
14503
+ * @see https://code.claude.com/docs/en/plugins-reference
14504
+ */
14505
+ static getConverterConfig() {
14506
+ return {
14507
+ ...super.getConverterConfig(),
14508
+ projectDirVar: "$CLAUDE_PLUGIN_ROOT"
14509
+ };
14510
+ }
13994
14511
  static getSettablePaths() {
13995
14512
  return {
13996
14513
  relativeDirPath: CLAUDECODE_PLUGIN_HOOKS_DIR,
@@ -14013,6 +14530,10 @@ const CODEXCLI_CONVERTER_CONFIG = {
14013
14530
  }, {
14014
14531
  canonical: "statusMessage",
14015
14532
  tool: "statusMessage"
14533
+ }],
14534
+ numberPassthroughFields: [{
14535
+ canonical: "additionalContextLimit",
14536
+ tool: "additionalContextLimit"
14016
14537
  }]
14017
14538
  };
14018
14539
  /**
@@ -14102,13 +14623,14 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14102
14623
  validate
14103
14624
  });
14104
14625
  }
14105
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
14626
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
14106
14627
  const paths = CodexcliHooks.getSettablePaths({ global });
14107
14628
  const config = rulesyncHooks.getJson();
14108
14629
  const codexHooks = canonicalToToolHooks({
14109
14630
  config,
14110
14631
  toolOverrideHooks: config.codexcli?.hooks,
14111
- converterConfig: CODEXCLI_CONVERTER_CONFIG
14632
+ converterConfig: CODEXCLI_CONVERTER_CONFIG,
14633
+ logger
14112
14634
  });
14113
14635
  const fileContent = JSON.stringify({ hooks: codexHooks }, null, 2);
14114
14636
  return new CodexcliHooks({
@@ -14119,7 +14641,7 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14119
14641
  validate
14120
14642
  });
14121
14643
  }
14122
- toRulesyncHooks() {
14644
+ toRulesyncHooks({ logger } = {}) {
14123
14645
  let parsed;
14124
14646
  try {
14125
14647
  parsed = JSON.parse(this.getFileContent());
@@ -14128,7 +14650,8 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14128
14650
  }
14129
14651
  const hooks = toolHooksToCanonical({
14130
14652
  hooks: parsed.hooks,
14131
- converterConfig: CODEXCLI_CONVERTER_CONFIG
14653
+ converterConfig: CODEXCLI_CONVERTER_CONFIG,
14654
+ logger
14132
14655
  });
14133
14656
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
14134
14657
  hooks,
@@ -14367,13 +14890,13 @@ var CopilotHooks = class CopilotHooks extends ToolHooks {
14367
14890
  * under `.github/hooks/` is picked up automatically when the CLI is
14368
14891
  * invoked from the project root.
14369
14892
  *
14370
- * - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — chosen for
14371
- * consistency with the existing global Copilot CLI config layout (e.g.
14372
- * `~/.copilot/mcp-config.json` produced by `copilotcli-mcp.ts`). The
14373
- * official docs do not currently document a global hooks location, so
14374
- * this is a rulesync convention pending official documentation; we keep
14375
- * all rulesync-managed Copilot CLI files under the single `~/.copilot/`
14376
- * root and will revisit if the spec later mandates an alternate layout.
14893
+ * - **Global scope**: `~/.copilot/hooks/copilot-hooks.json` — the directory is
14894
+ * the documented user-level hooks location ("`*.json` files in the
14895
+ * user-level hooks directory. By default this is `~/.copilot/hooks/` on
14896
+ * macOS and Linux, or `%USERPROFILE%\.copilot\hooks\` on Windows"). Every
14897
+ * `*.json` in it is loaded, so the filename remains rulesync's choice, as it
14898
+ * is for project scope. `COPILOT_HOME` relocates the directory upstream
14899
+ * (`$COPILOT_HOME/hooks/`); rulesync does not read that variable yet.
14377
14900
  *
14378
14901
  * Hook entries on the six matcher-aware events (see
14379
14902
  * {@link COPILOTCLI_MATCHER_EVENTS}) may carry an optional `matcher` regex; it
@@ -14784,17 +15307,18 @@ const DEEPAGENTS_MCP_FILE_NAME = ".mcp.json";
14784
15307
  const DEEPAGENTS_HOOKS_FILE_NAME = "hooks.json";
14785
15308
  //#endregion
14786
15309
  //#region src/features/hooks/deepagents-hooks.ts
14787
- function isDeepagentsHooksFile(val) {
14788
- if (typeof val !== "object" || val === null || !("hooks" in val)) return false;
14789
- return Array.isArray(val.hooks);
15310
+ function isRecord(value) {
15311
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14790
15312
  }
14791
15313
  /**
14792
- * Convert canonical hooks config to deepagents flat array format.
15314
+ * Convert the canonical hooks config to the deepagents Hooks v2 document.
14793
15315
  *
14794
- * deepagents format:
14795
- * { "hooks": [{ "command": ["bash", "-c", "..."], "events": ["session.start"] }] }
15316
+ * ```json
15317
+ * { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "" }] }] } }
15318
+ * ```
14796
15319
  *
14797
- * Each canonical hook definition becomes one deepagents hook entry.
15320
+ * Definitions sharing an event and matcher land in one group, preserving their
15321
+ * authored order — upstream runs a group's handlers in sequence.
14798
15322
  */
14799
15323
  function canonicalToDeepagentsHooks(config) {
14800
15324
  const supported = new Set(DEEPAGENTS_HOOK_EVENTS);
@@ -14802,7 +15326,7 @@ function canonicalToDeepagentsHooks(config) {
14802
15326
  ...config.hooks,
14803
15327
  ...config.deepagents?.hooks
14804
15328
  };
14805
- const entries = [];
15329
+ const hooks = {};
14806
15330
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
14807
15331
  if (!supported.has(canonicalEvent)) continue;
14808
15332
  const deepagentsEvent = CANONICAL_TO_DEEPAGENTS_EVENT_NAMES[canonicalEvent];
@@ -14810,43 +15334,69 @@ function canonicalToDeepagentsHooks(config) {
14810
15334
  for (const def of definitions) {
14811
15335
  if ((def.type ?? "command") !== "command") continue;
14812
15336
  if (!def.command) continue;
14813
- if (def.matcher) continue;
14814
- entries.push({
14815
- command: [
14816
- "bash",
14817
- "-c",
14818
- def.command
14819
- ],
14820
- events: [deepagentsEvent]
15337
+ const handler = {
15338
+ type: "command",
15339
+ command: def.command
15340
+ };
15341
+ if (def.timeout !== void 0 && def.timeout !== null && def.timeout > 0) handler.timeout = def.timeout;
15342
+ if (def.statusMessage !== void 0 && def.statusMessage !== null) handler.statusMessage = def.statusMessage;
15343
+ const matcher = def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" ? def.matcher : void 0;
15344
+ const groups = hooks[deepagentsEvent] ??= [];
15345
+ const group = groups.find((candidate) => candidate.matcher === matcher);
15346
+ if (group) group.hooks.push(handler);
15347
+ else groups.push({
15348
+ ...matcher !== void 0 && { matcher },
15349
+ hooks: [handler]
14821
15350
  });
14822
15351
  }
14823
15352
  }
14824
- return entries;
15353
+ return hooks;
15354
+ }
15355
+ /**
15356
+ * Convert the Hooks v2 document back to the canonical hooks record.
15357
+ */
15358
+ function deepagentsToCanonicalHooks(hooks) {
15359
+ const canonical = {};
15360
+ for (const [deepagentsEvent, groups] of Object.entries(hooks)) {
15361
+ const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
15362
+ if (!canonicalEvent || !Array.isArray(groups)) continue;
15363
+ for (const group of groups) {
15364
+ if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
15365
+ for (const handler of group.hooks) {
15366
+ if (!isRecord(handler) || typeof handler.command !== "string") continue;
15367
+ const def = {
15368
+ type: "command",
15369
+ command: handler.command
15370
+ };
15371
+ if (typeof group.matcher === "string" && group.matcher !== "") def.matcher = group.matcher;
15372
+ if (typeof handler.timeout === "number") def.timeout = handler.timeout;
15373
+ if (typeof handler.statusMessage === "string") def.statusMessage = handler.statusMessage;
15374
+ (canonical[canonicalEvent] ??= []).push(def);
15375
+ }
15376
+ }
15377
+ }
15378
+ return canonical;
14825
15379
  }
14826
15380
  /**
14827
- * Convert deepagents flat array format back to canonical hooks record.
15381
+ * Read the pre-v2 flat list. deepagents still loads it until 2026-09-01, so a
15382
+ * `hooks.json` a user has not migrated yet is imported rather than discarded —
15383
+ * but rulesync only ever writes the v2 shape, so regenerating migrates it.
14828
15384
  */
14829
- function deepagentsToCanonicalHooks(hooksEntries) {
15385
+ function deepagentsLegacyToCanonicalHooks(entries) {
14830
15386
  const canonical = {};
14831
- for (const entry of hooksEntries) {
14832
- if (typeof entry !== "object" || entry === null) continue;
14833
- if (!Array.isArray(entry.command) || entry.command.length === 0) continue;
14834
- let command;
14835
- if (entry.command.length === 3 && entry.command[0] === "bash" && entry.command[1] === "-c") command = entry.command[2] ?? "";
14836
- else command = entry.command.join(" ");
14837
- const events = entry.events ?? [];
14838
- for (const deepagentsEvent of events) {
14839
- const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
15387
+ for (const entry of entries) {
15388
+ if (!isRecord(entry)) continue;
15389
+ const argv = entry.command;
15390
+ if (!Array.isArray(argv) || argv.length === 0) continue;
15391
+ const command = argv.length === 3 && argv[0] === "bash" && argv[1] === "-c" ? String(argv[2] ?? "") : argv.join(" ");
15392
+ const events = Array.isArray(entry.events) ? entry.events : [];
15393
+ for (const legacyEvent of events) {
15394
+ const canonicalEvent = typeof legacyEvent === "string" ? DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES[legacyEvent] : void 0;
14840
15395
  if (!canonicalEvent) continue;
14841
- const existing = canonical[canonicalEvent];
14842
- if (existing) existing.push({
15396
+ (canonical[canonicalEvent] ??= []).push({
14843
15397
  type: "command",
14844
15398
  command
14845
15399
  });
14846
- else canonical[canonicalEvent] = [{
14847
- type: "command",
14848
- command
14849
- }];
14850
15400
  }
14851
15401
  }
14852
15402
  return canonical;
@@ -14855,7 +15405,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14855
15405
  constructor(params) {
14856
15406
  super({
14857
15407
  ...params,
14858
- fileContent: params.fileContent ?? JSON.stringify({ hooks: [] }, null, 2)
15408
+ fileContent: params.fileContent ?? JSON.stringify({ hooks: {} }, null, 2)
14859
15409
  });
14860
15410
  }
14861
15411
  isDeletable() {
@@ -14869,7 +15419,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14869
15419
  }
14870
15420
  static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
14871
15421
  const paths = DeepagentsHooks.getSettablePaths({ global });
14872
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ hooks: [] }, null, 2);
15422
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ hooks: {} }, null, 2);
14873
15423
  return new DeepagentsHooks({
14874
15424
  outputRoot,
14875
15425
  relativeDirPath: paths.relativeDirPath,
@@ -14897,7 +15447,8 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14897
15447
  } catch (error) {
14898
15448
  throw new Error(`Failed to parse deepagents hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
14899
15449
  }
14900
- const hooks = deepagentsToCanonicalHooks(isDeepagentsHooksFile(parsed) ? parsed.hooks : []);
15450
+ const rawHooks = isRecord(parsed) ? parsed.hooks : void 0;
15451
+ const hooks = Array.isArray(rawHooks) ? deepagentsLegacyToCanonicalHooks(rawHooks) : isRecord(rawHooks) ? deepagentsToCanonicalHooks(rawHooks) : {};
14901
15452
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
14902
15453
  hooks,
14903
15454
  overrideKey: "deepagents"
@@ -14914,7 +15465,7 @@ var DeepagentsHooks = class DeepagentsHooks extends ToolHooks {
14914
15465
  outputRoot,
14915
15466
  relativeDirPath,
14916
15467
  relativeFilePath,
14917
- fileContent: JSON.stringify({ hooks: [] }, null, 2),
15468
+ fileContent: JSON.stringify({ hooks: {} }, null, 2),
14918
15469
  validate: false
14919
15470
  });
14920
15471
  }
@@ -15016,7 +15567,7 @@ var DevinHooks = class DevinHooks extends ToolHooks {
15016
15567
  validate
15017
15568
  });
15018
15569
  }
15019
- toRulesyncHooks() {
15570
+ toRulesyncHooks({ logger } = {}) {
15020
15571
  let parsed;
15021
15572
  try {
15022
15573
  parsed = JSON.parse(this.getFileContent());
@@ -15024,8 +15575,9 @@ var DevinHooks = class DevinHooks extends ToolHooks {
15024
15575
  throw new Error(`Failed to parse Devin hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
15025
15576
  }
15026
15577
  const hooks = toolHooksToCanonical({
15027
- hooks: this.getRelativeFilePath() === "config.json" ? isRecord(parsed) && isRecord(parsed.hooks) ? parsed.hooks : {} : parsed,
15028
- converterConfig: DEVIN_CONVERTER_CONFIG
15578
+ hooks: this.getRelativeFilePath() === "config.json" ? isRecord$1(parsed) && isRecord$1(parsed.hooks) ? parsed.hooks : {} : parsed,
15579
+ converterConfig: DEVIN_CONVERTER_CONFIG,
15580
+ logger
15029
15581
  });
15030
15582
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15031
15583
  hooks,
@@ -15056,7 +15608,13 @@ const FACTORYDROID_CONVERTER_CONFIG = {
15056
15608
  toolToCanonicalEventNames: FACTORYDROID_TO_CANONICAL_EVENT_NAMES,
15057
15609
  projectDirVar: "$FACTORY_PROJECT_DIR",
15058
15610
  prefixDotRelativeCommandsOnly: true,
15059
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15611
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15612
+ groupPassthroughFields: [{
15613
+ canonical: "commandRegex",
15614
+ tool: "commandRegex",
15615
+ valueType: "string",
15616
+ subdividesGroup: true
15617
+ }]
15060
15618
  };
15061
15619
  var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15062
15620
  constructor(params) {
@@ -15113,7 +15671,7 @@ var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15113
15671
  validate
15114
15672
  });
15115
15673
  }
15116
- toRulesyncHooks() {
15674
+ toRulesyncHooks({ logger } = {}) {
15117
15675
  let settings;
15118
15676
  try {
15119
15677
  settings = JSON.parse(this.getFileContent());
@@ -15122,7 +15680,8 @@ var FactorydroidHooks = class FactorydroidHooks extends ToolHooks {
15122
15680
  }
15123
15681
  const hooks = toolHooksToCanonical({
15124
15682
  hooks: settings.hooks,
15125
- converterConfig: FACTORYDROID_CONVERTER_CONFIG
15683
+ converterConfig: FACTORYDROID_CONVERTER_CONFIG,
15684
+ logger
15126
15685
  });
15127
15686
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15128
15687
  hooks,
@@ -15152,7 +15711,8 @@ const GOOSE_CONVERTER_CONFIG = {
15152
15711
  canonicalToToolEventNames: CANONICAL_TO_GOOSE_EVENT_NAMES,
15153
15712
  toolToCanonicalEventNames: GOOSE_TO_CANONICAL_EVENT_NAMES,
15154
15713
  projectDirVar: "",
15155
- supportedHookTypes: /* @__PURE__ */ new Set(["command"])
15714
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
15715
+ wildcardMatcherMeansAll: true
15156
15716
  };
15157
15717
  /**
15158
15718
  * Represents a Goose lifecycle hooks file.
@@ -15190,13 +15750,14 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15190
15750
  validate
15191
15751
  });
15192
15752
  }
15193
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
15753
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
15194
15754
  const paths = GooseHooks.getSettablePaths({ global });
15195
15755
  const config = rulesyncHooks.getJson();
15196
15756
  const gooseHooks = canonicalToToolHooks({
15197
15757
  config,
15198
15758
  toolOverrideHooks: config.goose?.hooks,
15199
- converterConfig: GOOSE_CONVERTER_CONFIG
15759
+ converterConfig: GOOSE_CONVERTER_CONFIG,
15760
+ logger
15200
15761
  });
15201
15762
  const fileContent = JSON.stringify({ hooks: gooseHooks }, null, 2);
15202
15763
  return new GooseHooks({
@@ -15207,7 +15768,7 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15207
15768
  validate
15208
15769
  });
15209
15770
  }
15210
- toRulesyncHooks() {
15771
+ toRulesyncHooks({ logger } = {}) {
15211
15772
  let parsed;
15212
15773
  try {
15213
15774
  parsed = JSON.parse(this.getFileContent());
@@ -15216,7 +15777,8 @@ var GooseHooks = class GooseHooks extends ToolHooks {
15216
15777
  }
15217
15778
  const hooks = toolHooksToCanonical({
15218
15779
  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,
15219
- converterConfig: GOOSE_CONVERTER_CONFIG
15780
+ converterConfig: GOOSE_CONVERTER_CONFIG,
15781
+ logger
15220
15782
  });
15221
15783
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15222
15784
  hooks,
@@ -15247,6 +15809,11 @@ const GROKCLI_CONVERTER_CONFIG = {
15247
15809
  toolToCanonicalEventNames: GROKCLI_TO_CANONICAL_EVENT_NAMES,
15248
15810
  projectDirVar: "",
15249
15811
  supportedHookTypes: /* @__PURE__ */ new Set(["command", "http"]),
15812
+ recordPassthroughFields: [{
15813
+ canonical: "env",
15814
+ tool: "env",
15815
+ commandOnly: true
15816
+ }],
15250
15817
  noMatcherEvents: /* @__PURE__ */ new Set([
15251
15818
  "sessionStart",
15252
15819
  "sessionEnd",
@@ -15315,7 +15882,7 @@ var GrokcliHooks = class GrokcliHooks extends ToolHooks {
15315
15882
  validate
15316
15883
  });
15317
15884
  }
15318
- toRulesyncHooks() {
15885
+ toRulesyncHooks({ logger } = {}) {
15319
15886
  let parsed;
15320
15887
  try {
15321
15888
  parsed = JSON.parse(this.getFileContent());
@@ -15323,8 +15890,9 @@ var GrokcliHooks = class GrokcliHooks extends ToolHooks {
15323
15890
  throw new Error(`Failed to parse Grok hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
15324
15891
  }
15325
15892
  const hooks = toolHooksToCanonical({
15326
- hooks: isRecord(parsed) && isRecord(parsed.hooks) ? parsed.hooks : {},
15327
- converterConfig: GROKCLI_CONVERTER_CONFIG
15893
+ hooks: isRecord$1(parsed) && isRecord$1(parsed.hooks) ? parsed.hooks : {},
15894
+ converterConfig: GROKCLI_CONVERTER_CONFIG,
15895
+ logger
15328
15896
  });
15329
15897
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15330
15898
  hooks,
@@ -15645,7 +16213,7 @@ var JunieHooks = class JunieHooks extends ToolHooks {
15645
16213
  validate
15646
16214
  });
15647
16215
  }
15648
- toRulesyncHooks() {
16216
+ toRulesyncHooks({ logger } = {}) {
15649
16217
  let settings;
15650
16218
  try {
15651
16219
  settings = JSON.parse(this.getFileContent());
@@ -15654,7 +16222,8 @@ var JunieHooks = class JunieHooks extends ToolHooks {
15654
16222
  }
15655
16223
  const hooks = toolHooksToCanonical({
15656
16224
  hooks: settings.hooks,
15657
- converterConfig: JUNIE_CONVERTER_CONFIG
16225
+ converterConfig: JUNIE_CONVERTER_CONFIG,
16226
+ logger
15658
16227
  });
15659
16228
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
15660
16229
  hooks,
@@ -16392,7 +16961,7 @@ function buildKiroIdeEntriesForEvent(trigger, definitions) {
16392
16961
  ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
16393
16962
  action,
16394
16963
  ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
16395
- enabled: true
16964
+ enabled: def.enabled ?? true
16396
16965
  });
16397
16966
  }
16398
16967
  return entries;
@@ -16432,6 +17001,7 @@ function kiroIdeHooksToCanonical(entries) {
16432
17001
  if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
16433
17002
  if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
16434
17003
  if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17004
+ if (entry.enabled === false) def.enabled = false;
16435
17005
  (canonical[eventName] ??= []).push(def);
16436
17006
  }
16437
17007
  return canonical;
@@ -17376,6 +17946,37 @@ function unsupportedEventNames(params) {
17376
17946
  const eventNames = factory.passthroughOverrideEvents ? Object.keys(sharedHooks) : Object.keys(effectiveHooks);
17377
17947
  return [...new Set(eventNames)].filter((e) => !supportedEvents.has(e));
17378
17948
  }
17949
+ /**
17950
+ * A logger whose warnings name the tool being converted, matching the rest of
17951
+ * this processor's warnings: the shared hooks converter says what is wrong but
17952
+ * not which tool's file it is reading or writing, and one run walks them all.
17953
+ *
17954
+ * Every method delegates explicitly rather than through a prototype, so the
17955
+ * real logger keeps owning its state — a wrapper that inherited it would
17956
+ * absorb the writes `configure` and `outputJson` make.
17957
+ */
17958
+ function withToolTargetPrefix({ logger, toolTarget }) {
17959
+ return {
17960
+ configure: (options) => logger.configure(options),
17961
+ get verbose() {
17962
+ return logger.verbose;
17963
+ },
17964
+ get silent() {
17965
+ return logger.silent;
17966
+ },
17967
+ get jsonMode() {
17968
+ return logger.jsonMode;
17969
+ },
17970
+ captureData: (key, value) => logger.captureData(key, value),
17971
+ getJsonData: () => logger.getJsonData(),
17972
+ outputJson: (success, error) => logger.outputJson(success, error),
17973
+ info: (message, ...args) => logger.info(message, ...args),
17974
+ success: (message, ...args) => logger.success(message, ...args),
17975
+ warn: (message, ...args) => logger.warn(`For ${toolTarget}: ${message}`, ...args),
17976
+ error: (message, code, ...args) => logger.error(message, code, ...args),
17977
+ debug: (message, ...args) => logger.debug(message, ...args)
17978
+ };
17979
+ }
17379
17980
  function unsupportedMatcherEventNames({ factory, effectiveHooks }) {
17380
17981
  if (factory.supportsMatcher && !factory.matcherEvents) return [];
17381
17982
  const matcherEvents = factory.matcherEvents ? new Set(factory.matcherEvents) : void 0;
@@ -17598,13 +18199,13 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
17598
18199
  ["deepagents", {
17599
18200
  class: DeepagentsHooks,
17600
18201
  meta: {
17601
- supportsProject: false,
18202
+ supportsProject: true,
17602
18203
  supportsGlobal: true,
17603
18204
  supportsImport: true
17604
18205
  },
17605
18206
  supportedEvents: DEEPAGENTS_HOOK_EVENTS,
17606
18207
  supportedHookTypes: ["command"],
17607
- supportsMatcher: false
18208
+ supportsMatcher: true
17608
18209
  }],
17609
18210
  ["kiro", {
17610
18211
  class: KiroHooks,
@@ -17816,6 +18417,15 @@ var HooksProcessor = class extends FeatureProcessor {
17816
18417
  }
17817
18418
  for (const [hookType, events] of unsupportedTypeToEvents) this.logger.warn(`Skipped ${hookType}-type hook(s) for ${this.toolTarget} (not supported): ${Array.from(events).join(", ")}`);
17818
18419
  }
18420
+ if (this.toolTarget !== "kiro-ide") {
18421
+ const skippedEvents = new Set(unsupportedEventNames({
18422
+ factory,
18423
+ sharedHooks,
18424
+ effectiveHooks
18425
+ }));
18426
+ const eventsWithDisabledHooks = Object.entries(sharedHooks).filter(([event, defs]) => !skippedEvents.has(event) && defs.some((def) => def.enabled === false)).map(([event]) => event);
18427
+ 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(", ")}`);
18428
+ }
17819
18429
  const eventsWithUnsupportedMatcher = unsupportedMatcherEventNames({
17820
18430
  factory,
17821
18431
  effectiveHooks
@@ -17826,7 +18436,10 @@ var HooksProcessor = class extends FeatureProcessor {
17826
18436
  rulesyncHooks,
17827
18437
  validate: true,
17828
18438
  global: this.global,
17829
- logger: this.logger
18439
+ logger: withToolTargetPrefix({
18440
+ logger: this.logger,
18441
+ toolTarget: this.toolTarget
18442
+ })
17830
18443
  })];
17831
18444
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
17832
18445
  outputRoot: this.outputRoot,
@@ -17836,7 +18449,12 @@ var HooksProcessor = class extends FeatureProcessor {
17836
18449
  return result;
17837
18450
  }
17838
18451
  async convertToolFilesToRulesyncFiles(toolFiles) {
17839
- return toolFiles.filter((f) => f instanceof ToolHooks).map((h) => h.toRulesyncHooks({ logger: this.logger }));
18452
+ const hooks = toolFiles.filter((f) => f instanceof ToolHooks);
18453
+ const logger = withToolTargetPrefix({
18454
+ logger: this.logger,
18455
+ toolTarget: this.toolTarget
18456
+ });
18457
+ return hooks.map((h) => h.toRulesyncHooks({ logger }));
17840
18458
  }
17841
18459
  static getToolTargets({ global = false, importOnly = false } = {}) {
17842
18460
  if (global) return importOnly ? hooksProcessorToolTargetsGlobalImportable : hooksProcessorToolTargetsGlobal;
@@ -19479,9 +20097,9 @@ function parseAmpSettingsJsonc(fileContent) {
19479
20097
  }
19480
20098
  function filterMcpServers(mcpServers) {
19481
20099
  const filtered = {};
19482
- if (!isRecord(mcpServers)) return filtered;
20100
+ if (!isRecord$1(mcpServers)) return filtered;
19483
20101
  for (const [name, config] of Object.entries(mcpServers)) {
19484
- if (isPrototypePollutionKey(name) || !isRecord(config)) continue;
20102
+ if (isPrototypePollutionKey(name) || !isRecord$1(config)) continue;
19485
20103
  const filteredConfig = {};
19486
20104
  for (const [key, value] of Object.entries(config)) {
19487
20105
  if (isPrototypePollutionKey(key)) continue;
@@ -19596,7 +20214,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
19596
20214
  success: true,
19597
20215
  error: null
19598
20216
  };
19599
- if (!isRecord(mcpServers)) return {
20217
+ if (!isRecord$1(mcpServers)) return {
19600
20218
  success: false,
19601
20219
  error: /* @__PURE__ */ new Error(`${AMP_MCP_SERVERS_KEY} must be a JSON object`)
19602
20220
  };
@@ -19605,7 +20223,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
19605
20223
  success: false,
19606
20224
  error: /* @__PURE__ */ new Error(`Server name "${serverName}" is a prototype pollution key and is not allowed`)
19607
20225
  };
19608
- if (!isRecord(serverConfig)) return {
20226
+ if (!isRecord$1(serverConfig)) return {
19609
20227
  success: false,
19610
20228
  error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
19611
20229
  };
@@ -20205,13 +20823,13 @@ function normalizeCodexMcpServerName(name) {
20205
20823
  function convertFromCodexFormat(codexMcp) {
20206
20824
  const result = {};
20207
20825
  for (const [name, config] of Object.entries(codexMcp)) {
20208
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
20826
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
20209
20827
  const converted = {};
20210
20828
  for (const [key, value] of Object.entries(config)) {
20211
20829
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20212
20830
  if (key === "enabled") {
20213
20831
  if (value === false) converted["disabled"] = true;
20214
- } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthFromCodex(value);
20832
+ } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthFromCodex(value);
20215
20833
  else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
20216
20834
  const mappedKey = CODEX_TO_RULESYNC_FIELD_MAP[key];
20217
20835
  if (mappedKey) if (isValidRenamedArray(key, value)) converted[mappedKey] = value;
@@ -20230,7 +20848,7 @@ function convertToCodexFormat(mcpServers) {
20230
20848
  const result = {};
20231
20849
  const originalNames = /* @__PURE__ */ new Map();
20232
20850
  for (const [name, config] of Object.entries(mcpServers)) {
20233
- if (!isRecord(config)) continue;
20851
+ if (!isRecord$1(config)) continue;
20234
20852
  const { codexName, usedFallback } = normalizeCodexMcpServerName(name);
20235
20853
  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.`);
20236
20854
  const converted = {};
@@ -20238,7 +20856,7 @@ function convertToCodexFormat(mcpServers) {
20238
20856
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20239
20857
  if (key === "disabled") {
20240
20858
  if (value === true) converted["enabled"] = false;
20241
- } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthToCodex(value);
20859
+ } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthToCodex(value);
20242
20860
  else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
20243
20861
  const mappedKey = RULESYNC_TO_CODEX_FIELD_MAP[key];
20244
20862
  if (mappedKey) if (isValidRenamedArray(key, value)) converted[mappedKey] = value;
@@ -20314,21 +20932,21 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
20314
20932
  const strippedMcpServers = rulesyncMcp.getMcpServers();
20315
20933
  const rawMcpServers = rulesyncMcp.getJson().mcpServers;
20316
20934
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
20317
- const rawServer = isRecord(rawMcpServers) ? rawMcpServers[serverName] : void 0;
20935
+ const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
20318
20936
  return [serverName, {
20319
20937
  ...serverConfig,
20320
- ...isRecord(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
20321
- ...isRecord(rawServer) && typeof rawServer.experimental_environment === "string" ? { experimentalEnvironment: rawServer.experimental_environment } : {},
20322
- ...isRecord(rawServer) && typeof rawServer.experimentalEnvironment === "string" ? { experimentalEnvironment: rawServer.experimentalEnvironment } : {}
20938
+ ...isRecord$1(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
20939
+ ...isRecord$1(rawServer) && typeof rawServer.experimental_environment === "string" ? { experimentalEnvironment: rawServer.experimental_environment } : {},
20940
+ ...isRecord$1(rawServer) && typeof rawServer.experimentalEnvironment === "string" ? { experimentalEnvironment: rawServer.experimentalEnvironment } : {}
20323
20941
  }];
20324
20942
  })));
20325
20943
  const filteredMcpServers = this.removeEmptyEntries(converted);
20326
20944
  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`);
20327
- const existingMcpServers = isRecord(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
20945
+ const existingMcpServers = isRecord$1(configToml["mcp_servers"]) ? configToml["mcp_servers"] : {};
20328
20946
  const mergedMcpServers = Object.fromEntries(Object.entries(filteredMcpServers).map(([name, serverConfig]) => {
20329
- const existingServer = isRecord(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
20947
+ const existingServer = isRecord$1(existingMcpServers[name]) ? existingMcpServers[name] : void 0;
20330
20948
  const serverRecord = serverConfig;
20331
- if (existingServer && isRecord(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
20949
+ if (existingServer && isRecord$1(existingServer["tools"]) && !("tools" in serverRecord)) return [name, {
20332
20950
  ...serverRecord,
20333
20951
  tools: existingServer["tools"]
20334
20952
  }];
@@ -21197,6 +21815,27 @@ function resolveGooseType(config, url) {
21197
21815
  return canonicalTransport(config) === "builtin" ? "builtin" : "stdio";
21198
21816
  }
21199
21817
  /**
21818
+ * The Goose extension types that carry an MCP server. Goose also documents
21819
+ * `builtin`, `platform`, `frontend` and `inline_python` extensions, which have
21820
+ * no canonical MCP counterpart: they name capabilities Goose provides itself
21821
+ * rather than a server rulesync could describe.
21822
+ */
21823
+ const GOOSE_MCP_EXTENSION_TYPES = /* @__PURE__ */ new Set([
21824
+ "stdio",
21825
+ "streamable_http",
21826
+ "sse"
21827
+ ]);
21828
+ /**
21829
+ * Resolves the Goose extension type of an existing `extensions:` entry the way
21830
+ * Goose itself reads it: the declared `type`, or the shape of the entry when
21831
+ * the key is absent.
21832
+ */
21833
+ function existingExtensionType(ext) {
21834
+ if (typeof ext.type === "string") return ext.type;
21835
+ if (typeof ext.cmd === "string") return "stdio";
21836
+ if (typeof ext.uri === "string") return "streamable_http";
21837
+ }
21838
+ /**
21200
21839
  * Resolves the canonical timeout for a server (`timeout` or `networkTimeout`).
21201
21840
  */
21202
21841
  function resolveGooseTimeout(config) {
@@ -21222,15 +21861,20 @@ function applyGooseStdioFields(ext, config) {
21222
21861
  /**
21223
21862
  * Converts a single rulesync canonical MCP server into a Goose `extensions:` entry.
21224
21863
  */
21225
- function convertServerToGooseExtension(name, config) {
21864
+ function convertServerToGooseExtension(name, config, logger) {
21226
21865
  const url = resolveGooseUrl(config);
21227
21866
  const gooseType = resolveGooseType(config, url);
21228
21867
  const ext = {
21229
21868
  name,
21230
21869
  type: gooseType
21231
21870
  };
21232
- if (gooseType === "stdio") applyGooseStdioFields(ext, config);
21233
- else if (gooseType === "sse" || gooseType === "streamable_http") {
21871
+ if (gooseType === "stdio") {
21872
+ applyGooseStdioFields(ext, config);
21873
+ if (typeof ext.cmd !== "string" || ext.cmd === "") {
21874
+ 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.`);
21875
+ return;
21876
+ }
21877
+ } else if (gooseType === "sse" || gooseType === "streamable_http") {
21234
21878
  if (url !== void 0) ext.uri = url;
21235
21879
  if (isPlainObject$1(config.headers)) ext.headers = omitPrototypePollutionKeys(config.headers);
21236
21880
  }
@@ -21245,13 +21889,41 @@ function convertServerToGooseExtension(name, config) {
21245
21889
  * Goose uses a non-standard schema: `name`, `type` (`stdio` | `streamable_http`
21246
21890
  * | `sse` | `builtin`), `cmd`/`args`/`envs` for stdio, `uri`/`headers` for
21247
21891
  * remote, plus `enabled` and `timeout`.
21248
- */
21249
- function convertToGooseFormat(mcpServers) {
21250
- const extensions = {};
21892
+ *
21893
+ * `extensions:` is co-owned: alongside the MCP servers rulesync manages it also
21894
+ * holds Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions
21895
+ * (`developer`, `memory`, ...), which have no canonical MCP representation.
21896
+ * Those entries are carried over from `existingExtensions` untouched — removing
21897
+ * `developer` alone costs the agent its shell and text-editor tools. Only an
21898
+ * entry rulesync can positively identify as an MCP server is rulesync's to
21899
+ * replace, so a server deleted from `.rulesync/.mcp.json` is retracted (with a
21900
+ * warning naming it) while an entry of an unrecognized shape or a future
21901
+ * extension type is left alone rather than assumed to be ours.
21902
+ */
21903
+ function convertToGooseFormat({ mcpServers, existingExtensions, logger }) {
21904
+ const generated = {};
21251
21905
  for (const [name, config] of Object.entries(mcpServers)) {
21252
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
21253
- extensions[name] = convertServerToGooseExtension(name, config);
21906
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21907
+ const ext = convertServerToGooseExtension(name, config, logger);
21908
+ if (ext !== void 0) generated[name] = ext;
21254
21909
  }
21910
+ const extensions = {};
21911
+ const retracted = [];
21912
+ for (const [name, ext] of Object.entries(existingExtensions)) {
21913
+ if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
21914
+ const type = isRecord$1(ext) ? existingExtensionType(ext) : void 0;
21915
+ if (type !== void 0 && GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21916
+ if (!Object.hasOwn(generated, name)) retracted.push(name);
21917
+ continue;
21918
+ }
21919
+ if (!Object.hasOwn(generated, name)) {
21920
+ extensions[name] = ext;
21921
+ continue;
21922
+ }
21923
+ 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.`);
21924
+ }
21925
+ 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\`.`);
21926
+ Object.assign(extensions, generated);
21255
21927
  return extensions;
21256
21928
  }
21257
21929
  /**
@@ -21262,16 +21934,27 @@ function convertToGooseFormat(mcpServers) {
21262
21934
  * so both `url` and the Claude-specific `httpUrl` alias come back as `url`; and
21263
21935
  * the `streamable_http` type maps back to canonical `http`. These are the
21264
21936
  * canonical/preferred forms, so re-generating produces an equivalent config.
21937
+ *
21938
+ * Non-MCP extension types (`builtin`, `platform`, `frontend`, `inline_python`)
21939
+ * are skipped: they describe capabilities Goose provides itself, and importing
21940
+ * one would strip the type that makes it work — a `builtin` entry came back as
21941
+ * a `stdio` extension with no `cmd` that Goose cannot start. They stay in `config.yaml`,
21942
+ * which generation preserves.
21265
21943
  */
21266
21944
  function convertFromGooseFormat(extensions) {
21267
21945
  const result = {};
21946
+ const skipped = [];
21268
21947
  for (const [name, ext] of Object.entries(extensions)) {
21269
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(ext)) continue;
21948
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(ext)) continue;
21949
+ const type = existingExtensionType(ext);
21950
+ if (type === void 0 || !GOOSE_MCP_EXTENSION_TYPES.has(type)) {
21951
+ skipped.push(name);
21952
+ continue;
21953
+ }
21270
21954
  const server = {};
21271
- const type = typeof ext.type === "string" ? ext.type : void 0;
21272
21955
  if (type === "sse") server.type = "sse";
21273
21956
  else if (type === "streamable_http") server.type = "http";
21274
- else if (type === "stdio") server.type = "stdio";
21957
+ else server.type = "stdio";
21275
21958
  if (typeof ext.cmd === "string") server.command = ext.cmd;
21276
21959
  if (isStringArray$1(ext.args)) server.args = ext.args;
21277
21960
  if (isPlainObject$1(ext.envs)) server.env = omitPrototypePollutionKeys(ext.envs);
@@ -21281,6 +21964,7 @@ function convertFromGooseFormat(extensions) {
21281
21964
  if (typeof ext.timeout === "number") server.timeout = ext.timeout;
21282
21965
  result[name] = server;
21283
21966
  }
21967
+ 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.`);
21284
21968
  return result;
21285
21969
  }
21286
21970
  /**
@@ -21315,7 +21999,7 @@ function buildGoosePluginStdioServer(config) {
21315
21999
  function convertToGoosePluginMcpServers(mcpServers, logger) {
21316
22000
  const result = {};
21317
22001
  for (const [name, config] of Object.entries(mcpServers)) {
21318
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22002
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21319
22003
  const gooseType = resolveGooseType(config, resolveGooseUrl(config));
21320
22004
  if (gooseType !== "stdio") {
21321
22005
  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.`);
@@ -21356,7 +22040,7 @@ var GooseMcp = class GooseMcp extends ToolMcp {
21356
22040
  parsePluginManifest(fileContent) {
21357
22041
  try {
21358
22042
  const parsed = JSON.parse(fileContent);
21359
- return isRecord(parsed) ? parsed : {};
22043
+ return isRecord$1(parsed) ? parsed : {};
21360
22044
  } catch (error) {
21361
22045
  throw new Error(`Failed to parse Goose MCP manifest at ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(error)}`, { cause: error });
21362
22046
  }
@@ -21402,25 +22086,35 @@ var GooseMcp = class GooseMcp extends ToolMcp {
21402
22086
  global
21403
22087
  });
21404
22088
  }
21405
- const merged = {
21406
- ...parseGooseConfig(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "", paths.relativeDirPath, paths.relativeFilePath),
21407
- extensions: convertToGooseFormat(rulesyncMcp.getMcpServers())
21408
- };
22089
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
22090
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
22091
+ const config = parseGooseConfig(existingContent, paths.relativeDirPath, paths.relativeFilePath);
22092
+ const existingExtensions = isRecord$1(config.extensions) ? config.extensions : {};
21409
22093
  return new GooseMcp({
21410
22094
  outputRoot,
21411
22095
  relativeDirPath: paths.relativeDirPath,
21412
22096
  relativeFilePath: paths.relativeFilePath,
21413
- fileContent: dump(merged),
22097
+ fileContent: applySharedConfigPatch({
22098
+ fileKey: sharedConfigFileKey(paths),
22099
+ feature: "mcp",
22100
+ existingContent,
22101
+ patch: { extensions: convertToGooseFormat({
22102
+ mcpServers: rulesyncMcp.getMcpServers(),
22103
+ existingExtensions,
22104
+ logger
22105
+ }) },
22106
+ filePath
22107
+ }),
21414
22108
  validate,
21415
22109
  global
21416
22110
  });
21417
22111
  }
21418
22112
  toRulesyncMcp() {
21419
22113
  if (!this.global) {
21420
- const mcpServers = isRecord(this.config.mcpServers) ? this.config.mcpServers : {};
22114
+ const mcpServers = isRecord$1(this.config.mcpServers) ? this.config.mcpServers : {};
21421
22115
  return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
21422
22116
  }
21423
- const mcpServers = convertFromGooseFormat(isRecord(this.config.extensions) ? this.config.extensions : {});
22117
+ const mcpServers = convertFromGooseFormat(isRecord$1(this.config.extensions) ? this.config.extensions : {});
21424
22118
  return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers }, null, 2) });
21425
22119
  }
21426
22120
  validate() {
@@ -21456,7 +22150,7 @@ function convertToGrokFormat(mcpServers) {
21456
22150
  const result = {};
21457
22151
  for (const [name, config] of Object.entries(mcpServers)) {
21458
22152
  if (PROTOTYPE_POLLUTION_KEYS.has(name)) continue;
21459
- if (!isRecord(config)) continue;
22153
+ if (!isRecord$1(config)) continue;
21460
22154
  const converted = {};
21461
22155
  for (const [key, value] of Object.entries(config)) {
21462
22156
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
@@ -21471,7 +22165,7 @@ function convertToGrokFormat(mcpServers) {
21471
22165
  function convertFromGrokFormat(grokMcp) {
21472
22166
  const result = {};
21473
22167
  for (const [name, config] of Object.entries(grokMcp)) {
21474
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22168
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21475
22169
  const converted = {};
21476
22170
  for (const [key, value] of Object.entries(config)) {
21477
22171
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
@@ -21609,7 +22303,7 @@ function resolveHermesTimeout(config) {
21609
22303
  * import alike. See the Hermes mcp-config-reference.
21610
22304
  */
21611
22305
  function copyHermesOauth(source) {
21612
- if (!isRecord(source)) return;
22306
+ if (!isRecord$1(source)) return;
21613
22307
  const oauth = {};
21614
22308
  for (const key of [
21615
22309
  "redirect_uri",
@@ -21743,13 +22437,13 @@ function convertServerToHermes(config) {
21743
22437
  function convertToHermesFormat(mcpServers) {
21744
22438
  const result = {};
21745
22439
  for (const [name, config] of Object.entries(mcpServers)) {
21746
- if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord(config)) continue;
22440
+ if (PROTOTYPE_POLLUTION_KEYS.has(name) || !isRecord$1(config)) continue;
21747
22441
  result[name] = convertServerToHermes(config);
21748
22442
  }
21749
22443
  return result;
21750
22444
  }
21751
22445
  function mergeHermesMcpServers(config, mcpServers) {
21752
- const existingMcpServers = isRecord(config.mcp_servers) ? config.mcp_servers : {};
22446
+ const existingMcpServers = isRecord$1(config.mcp_servers) ? config.mcp_servers : {};
21753
22447
  return {
21754
22448
  ...config,
21755
22449
  mcp_servers: {
@@ -21768,7 +22462,7 @@ function convertFromHermesFormat(mcpServers) {
21768
22462
  const result = {};
21769
22463
  const hermesOverrides = {};
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
  const server = {};
21773
22467
  if (typeof config.command === "string") server.command = config.command;
21774
22468
  if (isStringArray$1(config.args)) server.args = config.args;
@@ -21777,7 +22471,7 @@ function convertFromHermesFormat(mcpServers) {
21777
22471
  if (isPlainObject$1(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
21778
22472
  if (config.enabled === false) server.disabled = true;
21779
22473
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
21780
- if (isRecord(config.tools)) applyHermesToolsBlock(config.tools, server);
22474
+ if (isRecord$1(config.tools)) applyHermesToolsBlock(config.tools, server);
21781
22475
  result[name] = server;
21782
22476
  const hermesServer = { ...server };
21783
22477
  if (copyHermesAdvancedFields(config, hermesServer)) hermesOverrides[name] = hermesServer;
@@ -21816,7 +22510,7 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
21816
22510
  const merged = mergeHermesMcpServers(parseSharedConfig({
21817
22511
  format: "yaml",
21818
22512
  fileContent
21819
- }), isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {});
22513
+ }), isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {});
21820
22514
  this.config = merged;
21821
22515
  super.setFileContent(applySharedConfigPatch({
21822
22516
  fileKey: getHermesagentConfigSharedFileKey({ global: this.global }),
@@ -21880,7 +22574,7 @@ var HermesagentMcp = class HermesagentMcp extends ToolMcp {
21880
22574
  });
21881
22575
  }
21882
22576
  toRulesyncMcp() {
21883
- const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {});
22577
+ const { mcpServers: servers, hermesOverrides } = convertFromHermesFormat(isRecord$1(this.config.mcp_servers) ? this.config.mcp_servers : {});
21884
22578
  return this.toRulesyncMcpDefault({
21885
22579
  outputRoot: getHermesagentRulesyncOutputRoot({
21886
22580
  nativeOutputRoot: this.outputRoot,
@@ -22192,7 +22886,7 @@ function convertServerToKiloFormat(serverName, serverConfig, existingEntry, logg
22192
22886
  */
22193
22887
  function readExistingKiloMcpEntries(fileContent) {
22194
22888
  const mcp = parse(fileContent || "{}")?.mcp;
22195
- if (!isRecord(mcp)) return {};
22889
+ if (!isRecord$1(mcp)) return {};
22196
22890
  const entries = {};
22197
22891
  for (const [serverName, entry] of Object.entries(mcp)) {
22198
22892
  const result = KiloMcpServerSchema.safeParse(entry);
@@ -22496,7 +23190,7 @@ async function readKimiCodeConfig({ outputRoot }) {
22496
23190
  return {
22497
23191
  parsed: true,
22498
23192
  content,
22499
- mcp: isRecord(mcp) ? mcp : {}
23193
+ mcp: isRecord$1(mcp) ? mcp : {}
22500
23194
  };
22501
23195
  } catch {
22502
23196
  return {
@@ -22650,7 +23344,7 @@ var KimiCodeMcp = class KimiCodeMcp extends ToolMcp {
22650
23344
  static async getAuxiliaryFiles({ outputRoot = process.cwd(), global = false, rulesyncMcp, logger }) {
22651
23345
  if (!global) return [];
22652
23346
  const block = rulesyncMcp.getJson()["kimi-code"];
22653
- if (!isRecord(block)) return [];
23347
+ if (!isRecord$1(block)) return [];
22654
23348
  const startupTimeoutMs = typeof block.startupTimeoutMs === "number" ? block.startupTimeoutMs : void 0;
22655
23349
  const toolTimeoutMs = typeof block.toolTimeoutMs === "number" ? block.toolTimeoutMs : void 0;
22656
23350
  if (startupTimeoutMs === void 0 && toolTimeoutMs === void 0) return [];
@@ -22703,6 +23397,68 @@ var KimiCodeMcp = class KimiCodeMcp extends ToolMcp {
22703
23397
  };
22704
23398
  //#endregion
22705
23399
  //#region src/features/mcp/kiro-mcp.ts
23400
+ /**
23401
+ * Union of two optional string lists, preserving order and dropping duplicates.
23402
+ * Returns `undefined` only when neither side was authored at all, so the caller
23403
+ * omits the key entirely rather than writing an empty array — but an explicitly
23404
+ * authored `[]` is kept, which keeps import → generate idempotent.
23405
+ */
23406
+ function mergeToolLists(...lists) {
23407
+ if (lists.every((list) => list === void 0)) return void 0;
23408
+ const merged = [];
23409
+ for (const list of lists) for (const tool of list ?? []) if (!merged.includes(tool)) merged.push(tool);
23410
+ return merged;
23411
+ }
23412
+ /**
23413
+ * Translate rulesync's Kiro-only authoring keys onto the field names Kiro
23414
+ * actually reads in `mcp.json`.
23415
+ *
23416
+ * - `kiroAutoApprove` → `autoApprove` (tools run without a confirmation prompt)
23417
+ * - `kiroAutoBlock` → `disabledTools` (tools hidden from the agent)
23418
+ *
23419
+ * `disabledTools` is the only block list Kiro reads, and it is also a canonical
23420
+ * rulesync field, so `kiroAutoBlock` is a redundant spelling of it. Prefer the
23421
+ * canonical field; see the note on `kiroAutoBlock` in `src/types/mcp.ts`.
23422
+ *
23423
+ * Both native names are documented per-server fields, so a config that already
23424
+ * spells them natively keeps working: the two lists are merged rather than
23425
+ * one overwriting the other.
23426
+ * @see https://kiro.dev/docs/mcp/configuration/
23427
+ */
23428
+ function toKiroMcpServers(servers) {
23429
+ return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
23430
+ const { kiroAutoApprove, kiroAutoBlock, disabledTools, ...rest } = server;
23431
+ const autoApprove = mergeToolLists(isStringArray$1(rest.autoApprove) ? rest.autoApprove : void 0, kiroAutoApprove);
23432
+ const disabled = mergeToolLists(disabledTools, kiroAutoBlock);
23433
+ return [name, {
23434
+ ...rest,
23435
+ ...autoApprove !== void 0 && { autoApprove },
23436
+ ...disabled !== void 0 && { disabledTools: disabled }
23437
+ }];
23438
+ }));
23439
+ }
23440
+ /**
23441
+ * Import direction of {@link toKiroMcpServers}: Kiro's `autoApprove` becomes the
23442
+ * rulesync-only `kiroAutoApprove` so a regenerate reproduces it. `disabledTools`
23443
+ * is left alone — it is already a canonical rulesync key with the same meaning,
23444
+ * so `kiroAutoBlock` deliberately has no import counterpart.
23445
+ *
23446
+ * Only a genuine string array is renamed. `kiroAutoApprove` is typed as one, so
23447
+ * moving a hand-written `"autoApprove": "all"` there would produce a
23448
+ * `.rulesync/mcp.jsonc` the next generate refuses to parse; such a value stays
23449
+ * under its original key and passes through untouched instead.
23450
+ */
23451
+ function fromKiroMcpServers(servers) {
23452
+ return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
23453
+ if (server === null || typeof server !== "object" || Array.isArray(server)) return [name, server];
23454
+ const { autoApprove, ...rest } = server;
23455
+ if (!isStringArray$1(autoApprove)) return [name, server];
23456
+ return [name, {
23457
+ ...rest,
23458
+ kiroAutoApprove: autoApprove
23459
+ }];
23460
+ }));
23461
+ }
22706
23462
  var KiroMcp = class KiroMcp extends ToolMcp {
22707
23463
  json;
22708
23464
  constructor(params) {
@@ -22731,7 +23487,7 @@ var KiroMcp = class KiroMcp extends ToolMcp {
22731
23487
  }
22732
23488
  static fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true }) {
22733
23489
  const paths = this.getSettablePaths();
22734
- const fileContent = JSON.stringify({ mcpServers: rulesyncMcp.getMcpServers() }, null, 2);
23490
+ const fileContent = JSON.stringify({ mcpServers: toKiroMcpServers(rulesyncMcp.getMcpServers()) }, null, 2);
22735
23491
  return new KiroMcp({
22736
23492
  outputRoot,
22737
23493
  relativeDirPath: paths.relativeDirPath,
@@ -22741,7 +23497,9 @@ var KiroMcp = class KiroMcp extends ToolMcp {
22741
23497
  });
22742
23498
  }
22743
23499
  toRulesyncMcp() {
22744
- return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: this.json.mcpServers ?? {} }, null, 2) });
23500
+ const mcpServers = this.json.mcpServers;
23501
+ const translated = isMcpServers(mcpServers) ? fromKiroMcpServers(mcpServers) : {};
23502
+ return this.toRulesyncMcpDefault({ fileContent: JSON.stringify({ mcpServers: translated }, null, 2) });
22745
23503
  }
22746
23504
  validate() {
22747
23505
  return {
@@ -23627,7 +24385,7 @@ async function readRovodevConfigYaml({ outputRoot }) {
23627
24385
  });
23628
24386
  }
23629
24387
  function disabledNamesOf(config) {
23630
- const mcpBlock = config && isRecord(config.mcp) ? config.mcp : {};
24388
+ const mcpBlock = config && isRecord$1(config.mcp) ? config.mcp : {};
23631
24389
  return isStringArray$1(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
23632
24390
  }
23633
24391
  /**
@@ -23746,7 +24504,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
23746
24504
  const managedNames = Object.keys(servers).filter((name) => toRovodevServer(name, servers[name]) !== null);
23747
24505
  const disabledNames = managedNames.filter((name) => {
23748
24506
  const server = servers[name];
23749
- return isRecord(server) && server.disabled === true;
24507
+ return isRecord$1(server) && server.disabled === true;
23750
24508
  });
23751
24509
  const existingContent = await readFileContentOrNull(join(outputRoot, ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)) ?? "";
23752
24510
  let existingParsed;
@@ -23760,7 +24518,7 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
23760
24518
  logger?.warn(`Skipping the Rovo Dev mcp.disabledMcpServers update: ${formatError(error)}`);
23761
24519
  return [];
23762
24520
  }
23763
- const existingMcp = isRecord(existingParsed.mcp) ? { ...existingParsed.mcp } : {};
24521
+ const existingMcp = isRecord$1(existingParsed.mcp) ? { ...existingParsed.mcp } : {};
23764
24522
  const existingDisabled = isStringArray$1(existingMcp.disabledMcpServers) ? existingMcp.disabledMcpServers : [];
23765
24523
  const managedNameSet = new Set(managedNames);
23766
24524
  const reEnabled = existingDisabled.filter((name) => managedNameSet.has(name) && !disabledNames.includes(name));
@@ -23952,7 +24710,7 @@ function deriveTransportAllowlist(servers) {
23952
24710
  http: false
23953
24711
  };
23954
24712
  for (const server of Object.values(servers)) {
23955
- if (!isRecord(server)) continue;
24713
+ if (!isRecord$1(server)) continue;
23956
24714
  const transport = transportOf(server);
23957
24715
  if (transport) allowlist[transport] = true;
23958
24716
  }
@@ -27587,13 +28345,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
27587
28345
  WebFetch: "webfetch",
27588
28346
  Mcp: "mcp"
27589
28347
  };
27590
- const MCP_CANONICAL_PREFIX$1 = "mcp__";
28348
+ const MCP_CANONICAL_PREFIX$2 = "mcp__";
27591
28349
  /**
27592
28350
  * Returns true if the canonical category is the per-tool MCP form
27593
28351
  * `mcp__<server>__<tool>`.
27594
28352
  */
27595
28353
  function isMcpScopedCategory(canonical) {
27596
- return canonical.startsWith(MCP_CANONICAL_PREFIX$1) && canonical.length > 5;
28354
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$2) && canonical.length > 5;
27597
28355
  }
27598
28356
  function toCursorType(canonical) {
27599
28357
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -27627,7 +28385,7 @@ function toCanonicalCategory$1(cursorType, pattern) {
27627
28385
  if (match) {
27628
28386
  const server = match[1] ?? "*";
27629
28387
  const tool = match[2] ?? "*";
27630
- return `${MCP_CANONICAL_PREFIX$1}${server}__${tool}`;
28388
+ return `${MCP_CANONICAL_PREFIX$2}${server}__${tool}`;
27631
28389
  }
27632
28390
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
27633
28391
  }
@@ -27856,7 +28614,7 @@ function convertCursorToRulesyncPermissions(params) {
27856
28614
  const { type, pattern } = parseCursorPermissionEntry(entry);
27857
28615
  const canonical = toCanonicalCategory$1(type, pattern);
27858
28616
  if (!permission[canonical]) permission[canonical] = {};
27859
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$1) ? "*" : pattern;
28617
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$2) ? "*" : pattern;
27860
28618
  permission[canonical][canonicalPattern] = action;
27861
28619
  }
27862
28620
  };
@@ -27991,14 +28749,14 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
27991
28749
  let settings;
27992
28750
  try {
27993
28751
  const parsed = JSON.parse(existingContent);
27994
- settings = isRecord(parsed) ? parsed : {};
28752
+ settings = isRecord$1(parsed) ? parsed : {};
27995
28753
  } catch (error) {
27996
28754
  throw new Error(`Failed to parse existing Devin config at ${filePath}: ${formatError(error)}`, { cause: error });
27997
28755
  }
27998
28756
  const config = rulesyncPermissions.getJson();
27999
28757
  const { allow, ask, deny } = convertRulesyncToDevinPermissions(config);
28000
28758
  const managedScopes = new Set(Object.keys(config.permission).map((category) => toDevinScope(category)));
28001
- const existingPermissions = isRecord(settings.permissions) ? settings.permissions : {};
28759
+ const existingPermissions = isRecord$1(settings.permissions) ? settings.permissions : {};
28002
28760
  const preserve = (entries) => (entries ?? []).filter((entry) => !managedScopes.has(parseDevinPermissionEntry(entry).scope));
28003
28761
  const mergedAllow = uniq([...preserve(existingPermissions.allow), ...allow].toSorted());
28004
28762
  const mergedAsk = uniq([...preserve(existingPermissions.ask), ...ask].toSorted());
@@ -28028,11 +28786,11 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
28028
28786
  let settings;
28029
28787
  try {
28030
28788
  const parsed = JSON.parse(this.getFileContent());
28031
- settings = isRecord(parsed) ? parsed : {};
28789
+ settings = isRecord$1(parsed) ? parsed : {};
28032
28790
  } catch (error) {
28033
28791
  throw new Error(`Failed to parse Devin permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28034
28792
  }
28035
- const permissions = isRecord(settings.permissions) ? settings.permissions : {};
28793
+ const permissions = isRecord$1(settings.permissions) ? settings.permissions : {};
28036
28794
  const config = convertDevinToRulesyncPermissions({
28037
28795
  allow: Array.isArray(permissions.allow) ? permissions.allow : [],
28038
28796
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
@@ -28119,7 +28877,8 @@ const FACTORYDROID_OVERRIDE_KEYS = [
28119
28877
  "interactionMode",
28120
28878
  "extraKnownMarketplaces",
28121
28879
  "enabledPlugins",
28122
- "hooksDisabled"
28880
+ "hooksDisabled",
28881
+ "disabledSkills"
28123
28882
  ];
28124
28883
  /**
28125
28884
  * Permissions adapter for Factory Droid.
@@ -28380,7 +29139,7 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
28380
29139
  } catch (error) {
28381
29140
  throw new Error(`Failed to parse existing Goose permission.yaml at ${filePath}: ${formatError(error)}`, { cause: error });
28382
29141
  }
28383
- const config = isRecord(parsed) ? { ...parsed } : {};
29142
+ const config = isRecord$1(parsed) ? { ...parsed } : {};
28384
29143
  const userPermission = convertRulesyncToGoosePermissionConfig({
28385
29144
  config: rulesyncPermissions.getJson(),
28386
29145
  logger
@@ -28403,8 +29162,8 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
28403
29162
  } catch (error) {
28404
29163
  throw new Error(`Failed to parse Goose permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28405
29164
  }
28406
- const config = isRecord(parsed) ? parsed : {};
28407
- const rulesyncConfig = convertGoosePermissionConfigToRulesync(isRecord(config[GOOSE_USER_KEY]) ? config[GOOSE_USER_KEY] : {});
29165
+ const config = isRecord$1(parsed) ? parsed : {};
29166
+ const rulesyncConfig = convertGoosePermissionConfigToRulesync(isRecord$1(config[GOOSE_USER_KEY]) ? config[GOOSE_USER_KEY] : {});
28408
29167
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
28409
29168
  }
28410
29169
  validate() {
@@ -28478,7 +29237,7 @@ const GROKCLI_UI_KEY = "ui";
28478
29237
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
28479
29238
  const GROKCLI_PERMISSION_KEY = "permission";
28480
29239
  const CATCH_ALL_PATTERN$2 = "*";
28481
- const MCP_CANONICAL_PREFIX = "mcp__";
29240
+ const MCP_CANONICAL_PREFIX$1 = "mcp__";
28482
29241
  const CATEGORY_TO_GROK_TOOL = {
28483
29242
  bash: "Bash",
28484
29243
  read: "Read",
@@ -28506,7 +29265,7 @@ const GROK_MCP_TOOL = "MCPTool";
28506
29265
  * concrete pattern emits `Tool(pattern)`.
28507
29266
  */
28508
29267
  function buildGrokEntry(category, pattern) {
28509
- if (category.startsWith(MCP_CANONICAL_PREFIX)) {
29268
+ if (category.startsWith(MCP_CANONICAL_PREFIX$1)) {
28510
29269
  const remainder = category.slice(5);
28511
29270
  return remainder.length > 0 ? `${GROK_MCP_TOOL}(${remainder})` : GROK_MCP_TOOL;
28512
29271
  }
@@ -28533,7 +29292,7 @@ function parseGrokEntry(entry) {
28533
29292
  inner = trimmed.slice(parenIndex + 1, -1).trim();
28534
29293
  }
28535
29294
  if (tool === GROK_MCP_TOOL) return inner.length > 0 ? {
28536
- category: `${MCP_CANONICAL_PREFIX}${inner}`,
29295
+ category: `${MCP_CANONICAL_PREFIX$1}${inner}`,
28537
29296
  pattern: CATCH_ALL_PATTERN$2
28538
29297
  } : {
28539
29298
  category: "mcp",
@@ -28631,7 +29390,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28631
29390
  throw new Error(`Failed to parse existing Grok config.toml at ${filePath}: ${formatError(error)}`, { cause: error });
28632
29391
  }
28633
29392
  const config = rulesyncPermissions.getJson();
28634
- const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
29393
+ const existingPermission = isRecord$1(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
28635
29394
  const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
28636
29395
  const permission = {
28637
29396
  ...existingPermission,
@@ -28640,7 +29399,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28640
29399
  ask: buckets.ask
28641
29400
  };
28642
29401
  const uiPatch = global ? { [GROKCLI_UI_KEY]: {
28643
- ...isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
29402
+ ...isRecord$1(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {},
28644
29403
  [GROKCLI_PERMISSION_MODE_KEY]: deriveGrokPermissionMode(config)
28645
29404
  } } : {};
28646
29405
  return new GrokcliPermissions({
@@ -28669,7 +29428,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
28669
29428
  } catch (error) {
28670
29429
  throw new Error(`Failed to parse Grok config.toml content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
28671
29430
  }
28672
- const fineGrained = parseGrokPermissionArrays(isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
29431
+ const fineGrained = parseGrokPermissionArrays(isRecord$1(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
28673
29432
  const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$2]: legacyModeAction(parsed) } } };
28674
29433
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
28675
29434
  }
@@ -28766,7 +29525,7 @@ function parseGrokPermissionArrays(permission) {
28766
29525
  * `always-approve` ⇒ `allow`; anything else (including a missing mode) ⇒ `ask`.
28767
29526
  */
28768
29527
  function legacyModeAction(parsed) {
28769
- return (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
29528
+ return (isRecord$1(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
28770
29529
  }
28771
29530
  /**
28772
29531
  * Collapse a rulesync permissions config into Grok's single coarse mode.
@@ -28822,12 +29581,12 @@ function withoutKey(record, key) {
28822
29581
  return Object.fromEntries(Object.entries(record).filter(([entryKey]) => entryKey !== key));
28823
29582
  }
28824
29583
  function buildHermesOverride(config, provenance) {
28825
- const base = isRecord(provenance.hermes) ? { ...provenance.hermes } : {};
28826
- const approvalsOverride = withoutKey(isRecord(config.approvals) ? config.approvals : {}, "deny");
29584
+ const base = isRecord$1(provenance.hermes) ? { ...provenance.hermes } : {};
29585
+ const approvalsOverride = withoutKey(isRecord$1(config.approvals) ? config.approvals : {}, "deny");
28827
29586
  if (Object.keys(approvalsOverride).length > 0) base.approvals = approvalsOverride;
28828
29587
  else delete base.approvals;
28829
- const security = isRecord(config.security) ? { ...config.security } : {};
28830
- const blocklist = isRecord(security.website_blocklist) ? { ...security.website_blocklist } : void 0;
29588
+ const security = isRecord$1(config.security) ? { ...config.security } : {};
29589
+ const blocklist = isRecord$1(security.website_blocklist) ? { ...security.website_blocklist } : void 0;
28831
29590
  if (blocklist?.enabled === true) {
28832
29591
  delete blocklist.domains;
28833
29592
  delete blocklist.enabled;
@@ -28836,7 +29595,7 @@ function buildHermesOverride(config, provenance) {
28836
29595
  else delete security.website_blocklist;
28837
29596
  if (Object.keys(security).length > 0) base.security = security;
28838
29597
  else delete base.security;
28839
- for (const key of ["skills", "memory"]) if (isRecord(config[key])) base[key] = config[key];
29598
+ for (const key of ["skills", "memory"]) if (isRecord$1(config[key])) base[key] = config[key];
28840
29599
  else delete base[key];
28841
29600
  return base;
28842
29601
  }
@@ -28908,7 +29667,7 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
28908
29667
  format: "yaml",
28909
29668
  fileContent: this.getFileContent()
28910
29669
  });
28911
- const permissionsRoot = isRecord(config.permissions) ? config.permissions : {};
29670
+ const permissionsRoot = isRecord$1(config.permissions) ? config.permissions : {};
28912
29671
  const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(permissionsRoot.rulesync);
28913
29672
  const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
28914
29673
  const permission = clonePermissionBlock(provenance.permission);
@@ -28916,14 +29675,14 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
28916
29675
  permission,
28917
29676
  commandAllowlist: isStringArray$1(config.command_allowlist) ? config.command_allowlist : []
28918
29677
  });
28919
- const approvals = isRecord(config.approvals) ? config.approvals : {};
29678
+ const approvals = isRecord$1(config.approvals) ? config.approvals : {};
28920
29679
  reconcileNativeDenies({
28921
29680
  permission,
28922
29681
  category: "bash",
28923
29682
  patterns: isStringArray$1(approvals.deny) ? approvals.deny : []
28924
29683
  });
28925
- const security = isRecord(config.security) ? config.security : {};
28926
- const websiteBlocklist = isRecord(security.website_blocklist) ? security.website_blocklist : {};
29684
+ const security = isRecord$1(config.security) ? config.security : {};
29685
+ const websiteBlocklist = isRecord$1(security.website_blocklist) ? security.website_blocklist : {};
28927
29686
  reconcileNativeDenies({
28928
29687
  permission,
28929
29688
  category: "webfetch",
@@ -29598,8 +30357,8 @@ function mergeKimiCodeToolsSection({ existingContent, patch }) {
29598
30357
  existing = void 0;
29599
30358
  }
29600
30359
  const merged = {
29601
- ...isRecord(existing) ? existing : {},
29602
- ...isRecord(patch.tools) ? patch.tools : {}
30360
+ ...isRecord$1(existing) ? existing : {},
30361
+ ...isRecord$1(patch.tools) ? patch.tools : {}
29603
30362
  };
29604
30363
  if (Object.keys(merged).length === 0) return;
29605
30364
  warnAboutMistypedToolLists(merged);
@@ -29623,7 +30382,7 @@ function mergeKimiCodeToolsSection({ existingContent, patch }) {
29623
30382
  * @see https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#tools
29624
30383
  */
29625
30384
  function buildKimiCodeToolsSection(tools) {
29626
- if (!isRecord(tools)) return;
30385
+ if (!isRecord$1(tools)) return;
29627
30386
  const section = Object.fromEntries(Object.entries(tools).filter(([key, value]) => key === "enabled" || key === "disabled" ? isStringList(value) : true));
29628
30387
  return Object.keys(section).length > 0 ? section : void 0;
29629
30388
  }
@@ -29722,7 +30481,7 @@ function preserveKimiCodeRules(rules) {
29722
30481
  nativeRules
29723
30482
  };
29724
30483
  for (const raw of rules) {
29725
- if (!isRecord(raw)) continue;
30484
+ if (!isRecord$1(raw)) continue;
29726
30485
  const decision = raw.decision;
29727
30486
  const pattern = raw.pattern;
29728
30487
  if (decision !== "allow" && decision !== "ask" && decision !== "deny" || typeof pattern !== "string") continue;
@@ -29837,7 +30596,7 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
29837
30596
  format: "toml",
29838
30597
  fileContent: this.getFileContent()
29839
30598
  });
29840
- const { permission, nativeRules } = preserveKimiCodeRules((isRecord(config.permission) ? config.permission : {}).rules);
30599
+ const { permission, nativeRules } = preserveKimiCodeRules((isRecord$1(config.permission) ? config.permission : {}).rules);
29841
30600
  const defaultPermissionMode = config.default_permission_mode;
29842
30601
  const tools = buildKimiCodeToolsSection(config.tools);
29843
30602
  const toolOverride = {
@@ -31114,7 +31873,7 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31114
31873
  } catch (error) {
31115
31874
  throw new Error(`Failed to parse existing Rovodev config at ${filePath}: ${formatError(error)}`, { cause: error });
31116
31875
  }
31117
- const config = isRecord(parsed) ? { ...parsed } : {};
31876
+ const config = isRecord$1(parsed) ? { ...parsed } : {};
31118
31877
  const rulesyncConfig = rulesyncPermissions.getJson();
31119
31878
  const toolPermissions = convertRulesyncToRovodevToolPermissions({
31120
31879
  config: rulesyncConfig,
@@ -31151,8 +31910,8 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31151
31910
  } catch (error) {
31152
31911
  throw new Error(`Failed to parse Rovodev permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
31153
31912
  }
31154
- const config = isRecord(parsed) ? parsed : {};
31155
- const rulesyncConfig = convertRovodevToolPermissionsToRulesync(isRecord(config.toolPermissions) ? config.toolPermissions : {});
31913
+ const config = isRecord$1(parsed) ? parsed : {};
31914
+ const rulesyncConfig = convertRovodevToolPermissionsToRulesync(isRecord$1(config.toolPermissions) ? config.toolPermissions : {});
31156
31915
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
31157
31916
  }
31158
31917
  validate() {
@@ -31179,14 +31938,14 @@ var RovodevPermissions = class RovodevPermissions extends ToolPermissions {
31179
31938
  * keys it does not are kept as-is.
31180
31939
  */
31181
31940
  function resolveToolPermissionsBlock({ existing, generated, sourceStatesRules, filePath, logger }) {
31182
- const existingToolPermissions = isRecord(existing) ? { ...existing } : {};
31941
+ const existingToolPermissions = isRecord$1(existing) ? { ...existing } : {};
31183
31942
  if (Object.keys(generated).length === 0 && sourceStatesRules) {
31184
- if (!isRecord(existing)) return;
31943
+ if (!isRecord$1(existing)) return;
31185
31944
  const strippedKeys = stripPermissiveOwnedValues(existingToolPermissions);
31186
31945
  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(", ")}.` : `.`));
31187
31946
  return existingToolPermissions;
31188
31947
  }
31189
- const hasExistingToolsRecord = isRecord(existingToolPermissions.tools);
31948
+ const hasExistingToolsRecord = isRecord$1(existingToolPermissions.tools);
31190
31949
  const existingTools = hasExistingToolsRecord ? { ...existingToolPermissions.tools } : {};
31191
31950
  warnAboutDroppedOwnedKeys({
31192
31951
  existingToolPermissions,
@@ -31230,7 +31989,7 @@ function warnAboutDroppedOwnedKeys({ existingToolPermissions, existingTools, gen
31230
31989
  */
31231
31990
  function stripPermissiveOwnedValues(toolPermissions) {
31232
31991
  const strippedKeys = [];
31233
- if (isRecord(toolPermissions.tools)) {
31992
+ if (isRecord$1(toolPermissions.tools)) {
31234
31993
  const tools = { ...toolPermissions.tools };
31235
31994
  for (const toolKey of MANAGED_TOOL_KEYS) if (tools[toolKey] === "allow") {
31236
31995
  delete tools[toolKey];
@@ -31252,14 +32011,14 @@ function stripPermissiveOwnedValues(toolPermissions) {
31252
32011
  strippedKeys.push("default");
31253
32012
  }
31254
32013
  const bash = toolPermissions.bash;
31255
- if (isRecord(bash)) {
32014
+ if (isRecord$1(bash)) {
31256
32015
  const stripped = { ...bash };
31257
32016
  if (stripped.default === "allow") {
31258
32017
  delete stripped.default;
31259
32018
  strippedKeys.push("bash.default");
31260
32019
  }
31261
32020
  if (Array.isArray(stripped.commands)) {
31262
- const kept = stripped.commands.filter((entry) => !(isRecord(entry) && entry.permission === "allow"));
32021
+ const kept = stripped.commands.filter((entry) => !(isRecord$1(entry) && entry.permission === "allow"));
31263
32022
  if (kept.length !== stripped.commands.length) strippedKeys.push("bash.commands");
31264
32023
  if (kept.length > 0) stripped.commands = kept;
31265
32024
  else delete stripped.commands;
@@ -31367,15 +32126,15 @@ function convertRovodevToolPermissionsToRulesync(toolPermissions) {
31367
32126
  const permission = {};
31368
32127
  if (isPermissionAction(toolPermissions.default)) permission[CATCH_ALL_PATTERN$1] = { [CATCH_ALL_PATTERN$1]: toolPermissions.default };
31369
32128
  const bash = toolPermissions.bash;
31370
- if (isRecord(bash)) {
32129
+ if (isRecord$1(bash)) {
31371
32130
  const bashRules = {};
31372
32131
  if (isPermissionAction(bash.default)) bashRules[CATCH_ALL_PATTERN$1] = bash.default;
31373
32132
  if (Array.isArray(bash.commands)) {
31374
- for (const entry of bash.commands) if (isRecord(entry) && typeof entry.command === "string" && isPermissionAction(entry.permission)) bashRules[entry.command] = entry.permission;
32133
+ for (const entry of bash.commands) if (isRecord$1(entry) && typeof entry.command === "string" && isPermissionAction(entry.permission)) bashRules[entry.command] = entry.permission;
31375
32134
  }
31376
32135
  if (Object.keys(bashRules).length > 0) permission.bash = bashRules;
31377
32136
  }
31378
- const nestedTools = isRecord(toolPermissions.tools) ? toolPermissions.tools : {};
32137
+ const nestedTools = isRecord$1(toolPermissions.tools) ? toolPermissions.tools : {};
31379
32138
  const implicitLevel = isPermissionAction(toolPermissions.default) ? toolPermissions.default : "ask";
31380
32139
  for (const category of new Set(Object.values(TOOL_KEY_TO_CATEGORY))) {
31381
32140
  const levels = Object.entries(TOOL_KEY_TO_CATEGORY).filter(([, mapped]) => mapped === category).map(([toolKey]) => {
@@ -32090,11 +32849,11 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32090
32849
  config,
32091
32850
  logger
32092
32851
  });
32093
- const agents = isRecord(settings.agents) ? { ...settings.agents } : {};
32094
- const profiles = isRecord(agents.profiles) ? { ...agents.profiles } : {};
32852
+ const agents = isRecord$1(settings.agents) ? { ...settings.agents } : {};
32853
+ const profiles = isRecord$1(agents.profiles) ? { ...agents.profiles } : {};
32095
32854
  const override = config.warp;
32096
- const executionProfileOverride = isRecord(override) && isRecord(override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
32097
- if (isRecord(override)) {
32855
+ const executionProfileOverride = isRecord$1(override) && isRecord$1(override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY]) ? override[WARP_EXECUTION_PROFILE_OVERRIDE_KEY] : void 0;
32856
+ if (isRecord$1(override)) {
32098
32857
  const { [WARP_EXECUTION_PROFILE_OVERRIDE_KEY]: _executionProfile, ...legacyOverride } = override;
32099
32858
  Object.assign(profiles, legacyOverride);
32100
32859
  }
@@ -32129,10 +32888,10 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32129
32888
  } catch (error) {
32130
32889
  throw new Error(`Failed to parse Warp permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
32131
32890
  }
32132
- const agents = isRecord(settings.agents) ? settings.agents : {};
32133
- const profiles = isRecord(agents.profiles) ? agents.profiles : {};
32134
- const executionProfiles = isRecord(agents[EXECUTION_PROFILES_KEY]) ? agents[EXECUTION_PROFILES_KEY] : void 0;
32135
- const defaultProfile = executionProfiles && isRecord(executionProfiles[DEFAULT_PROFILE_KEY]) ? executionProfiles[DEFAULT_PROFILE_KEY] : void 0;
32891
+ const agents = isRecord$1(settings.agents) ? settings.agents : {};
32892
+ const profiles = isRecord$1(agents.profiles) ? agents.profiles : {};
32893
+ const executionProfiles = isRecord$1(agents[EXECUTION_PROFILES_KEY]) ? agents[EXECUTION_PROFILES_KEY] : void 0;
32894
+ const defaultProfile = executionProfiles && isRecord$1(executionProfiles[DEFAULT_PROFILE_KEY]) ? executionProfiles[DEFAULT_PROFILE_KEY] : void 0;
32136
32895
  const config = convertWarpToRulesyncPermissions({
32137
32896
  allow: defaultProfile ? isStringArray$1(defaultProfile[PROFILE_ALLOWLIST_KEY]) ? defaultProfile[PROFILE_ALLOWLIST_KEY] : [] : isStringArray$1(profiles[ALLOWLIST_KEY]) ? profiles[ALLOWLIST_KEY] : [],
32138
32897
  deny: defaultProfile ? isStringArray$1(defaultProfile[PROFILE_DENYLIST_KEY]) ? defaultProfile[PROFILE_DENYLIST_KEY] : [] : isStringArray$1(profiles[DENYLIST_KEY]) ? profiles[DENYLIST_KEY] : []
@@ -32179,12 +32938,12 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32179
32938
  */
32180
32939
  function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, executionProfileOverride, logger }) {
32181
32940
  const hasOverrideKeys = executionProfileOverride !== void 0 && Object.keys(executionProfileOverride).length > 0;
32182
- if (!isRecord(agents[EXECUTION_PROFILES_KEY])) {
32941
+ if (!isRecord$1(agents[EXECUTION_PROFILES_KEY])) {
32183
32942
  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.");
32184
32943
  return;
32185
32944
  }
32186
32945
  const executionProfiles = { ...agents[EXECUTION_PROFILES_KEY] };
32187
- const defaultProfile = isRecord(executionProfiles[DEFAULT_PROFILE_KEY]) ? { ...executionProfiles[DEFAULT_PROFILE_KEY] } : {};
32946
+ const defaultProfile = isRecord$1(executionProfiles[DEFAULT_PROFILE_KEY]) ? { ...executionProfiles[DEFAULT_PROFILE_KEY] } : {};
32188
32947
  if (executionProfileOverride) Object.assign(defaultProfile, executionProfileOverride);
32189
32948
  if (mergedAllow.length > 0) defaultProfile[PROFILE_ALLOWLIST_KEY] = mergedAllow;
32190
32949
  else delete defaultProfile[PROFILE_ALLOWLIST_KEY];
@@ -32258,9 +33017,13 @@ const ZedToolPermissionsSchema = z.looseObject({
32258
33017
  default: z.optional(ZedPermissionActionSchema),
32259
33018
  tools: z.optional(z.record(z.string(), ZedToolPermissionSchema))
32260
33019
  });
33020
+ /** Canonical per-tool MCP category prefix: `mcp__<server>__<tool>`. */
33021
+ const MCP_CANONICAL_PREFIX = "mcp__";
33022
+ /** Zed's per-tool MCP name prefix: `mcp:<server>:<tool>`. */
33023
+ const MCP_ZED_PREFIX = "mcp:";
32261
33024
  /**
32262
33025
  * Mapping from rulesync canonical tool category names to Zed agent tool names.
32263
- * Unknown names are passed through as-is (e.g. `mcp:<server>:<tool>` keys).
33026
+ * Unknown names are passed through as-is.
32264
33027
  */
32265
33028
  const CANONICAL_TO_ZED_TOOL_NAMES = {
32266
33029
  bash: "terminal",
@@ -32270,13 +33033,93 @@ const CANONICAL_TO_ZED_TOOL_NAMES = {
32270
33033
  webfetch: "fetch",
32271
33034
  websearch: "search_web"
32272
33035
  };
33036
+ /**
33037
+ * Canonical categories whose Zed tool is not permission-gated. Zed's gated list
33038
+ * is `terminal`, `edit_file`, `write_file`, `delete_path`, `move_path`,
33039
+ * `copy_path`, `create_directory`, `fetch`, `search_web` and `skill`; the
33040
+ * read-only tools (`read_file`, `grep`, `find_path`, `list_directory`) sit in
33041
+ * Zed's own `EXCLUDED_TOOLS` and never call `decide_permission_from_settings`,
33042
+ * so a `tools.<name>` entry for one is config Zed never consults. Zed's real
33043
+ * read-denial surface is `private_files`, which the ignore feature owns.
33044
+ *
33045
+ * @see https://zed.dev/docs/ai/tool-permissions#supported-tools
33046
+ */
33047
+ const ZED_EXCLUDED_CANONICAL_CATEGORIES = /* @__PURE__ */ new Set([
33048
+ "read",
33049
+ "grep",
33050
+ "glob"
33051
+ ]);
33052
+ /** The Zed-side spellings of the same tools, for a category that names one directly. */
33053
+ const ZED_EXCLUDED_TOOL_NAMES = /* @__PURE__ */ new Set([
33054
+ "read_file",
33055
+ "grep",
33056
+ "find_path",
33057
+ "list_directory"
33058
+ ]);
33059
+ const isZedExcludedCategory = (category) => ZED_EXCLUDED_CANONICAL_CATEGORIES.has(category) || ZED_EXCLUDED_TOOL_NAMES.has(toZedToolName(category));
32273
33060
  const ZED_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_ZED_TOOL_NAMES).map(([k, v]) => [v, k]));
33061
+ /**
33062
+ * Zed addresses an MCP tool as `mcp:<server>:<tool>`, where rulesync's canonical
33063
+ * category is `mcp__<server>__<tool>`. Without this translation the canonical
33064
+ * spelling lands under a key Zed never looks up.
33065
+ *
33066
+ * Only the FIRST separator is split, matching the `cursor-permissions.ts`
33067
+ * precedent: upstream's `mcp_tool_id` concatenates the two names without
33068
+ * escaping either, so a tool called `create__issue` is legitimately
33069
+ * `mcp:github:create__issue`. Splitting every separator would rewrite that into
33070
+ * a third key on the next generate.
33071
+ *
33072
+ * @see https://zed.dev/docs/ai/tool-permissions
33073
+ */
32274
33074
  function toZedToolName(canonical) {
33075
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX)) {
33076
+ const [server, ...toolParts] = canonical.slice(5).split("__");
33077
+ const address = toolParts.length > 0 ? `${server}:${toolParts.join("__")}` : server ?? "";
33078
+ return `${MCP_ZED_PREFIX}${address}`;
33079
+ }
32275
33080
  return CANONICAL_TO_ZED_TOOL_NAMES[canonical] ?? canonical;
32276
33081
  }
32277
33082
  function toCanonicalToolName(zedName) {
33083
+ if (zedName.startsWith(MCP_ZED_PREFIX)) {
33084
+ const [server, ...toolParts] = zedName.slice(4).split(":");
33085
+ const address = toolParts.length > 0 ? `${server}__${toolParts.join(":")}` : server ?? "";
33086
+ return `${MCP_CANONICAL_PREFIX}${address}`;
33087
+ }
32278
33088
  return ZED_TO_CANONICAL_TOOL_NAMES[zedName] ?? zedName;
32279
33089
  }
33090
+ /**
33091
+ * Zed matches `always_allow`/`always_deny`/`always_confirm` regexes against the
33092
+ * tool's text input, and it dispatches every MCP tool with a single empty input
33093
+ * (`&[String::new()]`, commented upstream as "MCP tools are gated only by tool
33094
+ * id (no per-input pattern matching)"). The regexes still run, but against `""`,
33095
+ * so a pattern-scoped rule silently does something other than what its author
33096
+ * meant — and a non-matching `always_allow` downgrades the outcome to confirm.
33097
+ * Only the category's `*` rule (Zed's per-tool `default`) is therefore emitted.
33098
+ */
33099
+ const isMcpZedToolName = (zedName) => zedName.startsWith(MCP_ZED_PREFIX);
33100
+ /**
33101
+ * Zed looks a tool up by exact key on the full `mcp:<server>:<tool>` triple, so
33102
+ * an address is inert unless it names both a concrete server and a concrete
33103
+ * tool: a missing half matches nothing, and so does a wildcard, since Zed does
33104
+ * no glob or prefix matching on the key.
33105
+ */
33106
+ function isInertMcpAddress(zedToolName) {
33107
+ if (!isMcpZedToolName(zedToolName)) return false;
33108
+ const [server, ...toolParts] = zedToolName.slice(4).split(":");
33109
+ const tool = toolParts.join(":");
33110
+ return !server || server === "*" || !tool || tool === "*";
33111
+ }
33112
+ /**
33113
+ * Strip pattern-scoped rules from an MCP category, warning once about the ones
33114
+ * dropped. Non-MCP categories are returned untouched.
33115
+ */
33116
+ function withoutInertMcpPatterns({ category, zedToolName, rules, logger }) {
33117
+ if (!isMcpZedToolName(zedToolName)) return rules;
33118
+ const scopedPatterns = Object.keys(rules).filter((pattern) => pattern !== "*");
33119
+ if (scopedPatterns.length === 0) return rules;
33120
+ 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.`);
33121
+ return Object.fromEntries(Object.entries(rules).filter(([pattern]) => pattern === "*"));
33122
+ }
32280
33123
  const CANONICAL_TO_ZED_ACTION = {
32281
33124
  allow: "allow",
32282
33125
  ask: "confirm",
@@ -32319,6 +33162,53 @@ function buildZedToolPermission(rules) {
32319
33162
  if (alwaysConfirm.length > 0) tool.always_confirm = alwaysConfirm;
32320
33163
  return Object.keys(tool).length > 0 ? tool : null;
32321
33164
  }
33165
+ /**
33166
+ * Split a canonical permission block into the Zed shapes it maps onto, plus the
33167
+ * categories the caller should report as dropped.
33168
+ *
33169
+ * The canonical `*` category is the all-tools catch-all. Zed's counterpart is
33170
+ * `agent.tool_permissions.default` (rung 6 of its precedence ladder), not a
33171
+ * `tools["*"]` entry — `*` is not a Zed tool name, so writing one produces a
33172
+ * rule Zed silently ignores. Only the category's own `*` pattern can be
33173
+ * expressed there: Zed's global default carries no pattern list, so
33174
+ * pattern-scoped rules in the `*` category are dropped with a warning instead of
33175
+ * being emitted as inert config.
33176
+ */
33177
+ function buildZedToolPermissions({ permission, logger }) {
33178
+ let managedDefault;
33179
+ const managedTools = {};
33180
+ const excludedCategories = [];
33181
+ const inertMcpCategories = [];
33182
+ for (const [category, rules] of Object.entries(permission)) {
33183
+ if (category === "*") {
33184
+ for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
33185
+ 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.`);
33186
+ continue;
33187
+ }
33188
+ if (isZedExcludedCategory(category)) {
33189
+ if (Object.values(rules).some((action) => action === "deny" || action === "ask")) excludedCategories.push(category);
33190
+ continue;
33191
+ }
33192
+ const zedToolName = toZedToolName(category);
33193
+ if (isInertMcpAddress(zedToolName)) {
33194
+ inertMcpCategories.push(category);
33195
+ continue;
33196
+ }
33197
+ const tool = buildZedToolPermission(withoutInertMcpPatterns({
33198
+ category,
33199
+ zedToolName,
33200
+ rules,
33201
+ logger
33202
+ }));
33203
+ if (tool) managedTools[zedToolName] = tool;
33204
+ }
33205
+ return {
33206
+ managedDefault,
33207
+ managedTools,
33208
+ excludedCategories,
33209
+ inertMcpCategories
33210
+ };
33211
+ }
32322
33212
  function asRecord(value) {
32323
33213
  if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
32324
33214
  return Object.fromEntries(Object.entries(value));
@@ -32389,19 +33279,15 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
32389
33279
  const agent = asRecord(settings.agent);
32390
33280
  const toolPermissions = asRecord(agent.tool_permissions);
32391
33281
  const existingTools = asRecord(toolPermissions.tools);
32392
- let managedDefault;
32393
- const managedTools = {};
32394
- for (const [category, rules] of Object.entries(config.permission)) {
32395
- if (category === "*") {
32396
- for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
32397
- 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.`);
32398
- continue;
32399
- }
32400
- const tool = buildZedToolPermission(rules);
32401
- if (tool) managedTools[toZedToolName(category)] = tool;
32402
- }
33282
+ const { managedDefault, managedTools, excludedCategories, inertMcpCategories } = buildZedToolPermissions({
33283
+ permission: config.permission,
33284
+ logger
33285
+ });
33286
+ 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\`.`);
33287
+ 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.`);
32403
33288
  const managedToolNames = new Set(Object.keys(managedTools));
32404
33289
  if ("*" in config.permission) managedToolNames.add("*");
33290
+ for (const toolName of Object.keys(existingTools)) if (toolName.startsWith(MCP_CANONICAL_PREFIX)) managedToolNames.add(toolName);
32405
33291
  const preservedTools = Object.fromEntries(Object.entries(existingTools).filter(([toolName]) => !managedToolNames.has(toolName)));
32406
33292
  return new ZedPermissions({
32407
33293
  outputRoot,
@@ -33191,18 +34077,16 @@ var DirFeatureProcessor = class {
33191
34077
  })) dirHasChanges = true;
33192
34078
  }
33193
34079
  const otherFiles = aiDir.getOtherFiles();
33194
- const otherFileContents = [];
33195
34080
  for (const file of otherFiles) {
33196
- const contentWithNewline = addTrailingNewline(file.fileBuffer.toString("utf-8"));
33197
- otherFileContents.push(contentWithNewline);
33198
- if (!dirHasChanges) {
33199
- const filePath = join(dirPath, file.relativeFilePathToDirPath);
33200
- if (!fileContentsEquivalent({
33201
- filePath,
33202
- expected: contentWithNewline,
33203
- existing: await readFileContentOrNull(filePath)
33204
- })) dirHasChanges = true;
33205
- }
34081
+ if (dirHasChanges) break;
34082
+ const filePath = join(dirPath, file.relativeFilePathToDirPath);
34083
+ const existingBuffer = await readFileBufferOrNull(filePath);
34084
+ if (!companionFileContentsEquivalent({
34085
+ filePath,
34086
+ expected: file.fileBuffer,
34087
+ existing: existingBuffer,
34088
+ composed: file.composed
34089
+ })) dirHasChanges = true;
33206
34090
  }
33207
34091
  if (!dirHasChanges) continue;
33208
34092
  const relativeDir = aiDir.getRelativePathFromCwd();
@@ -33222,11 +34106,8 @@ var DirFeatureProcessor = class {
33222
34106
  await writeFileContent(join(dirPath, mainFile.name), mainFileContent);
33223
34107
  changedPaths.push(join(relativeDir, mainFile.name));
33224
34108
  }
33225
- for (const [i, file] of otherFiles.entries()) {
33226
- const filePath = join(dirPath, file.relativeFilePathToDirPath);
33227
- const content = otherFileContents[i];
33228
- 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.`);
33229
- await writeFileContent(filePath, content);
34109
+ for (const file of otherFiles) {
34110
+ await writeFileBuffer(join(dirPath, file.relativeFilePathToDirPath), file.fileBuffer);
33230
34111
  changedPaths.push(join(relativeDir, file.relativeFilePathToDirPath));
33231
34112
  }
33232
34113
  }
@@ -33952,6 +34833,39 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
33952
34833
  return frontmatter;
33953
34834
  }
33954
34835
  /**
34836
+ * Escapes the glob metacharacters in a directory path so it matches literally.
34837
+ * A real directory name may contain them — `app/[slug]` in a Next.js tree is
34838
+ * the common case, and unescaped `[slug]` reads as a bracket expression that
34839
+ * matches a different subtree (or nothing at all).
34840
+ *
34841
+ * @see https://code.claude.com/docs/en/memory
34842
+ */
34843
+ function escapeGlobLiteral(dirPath) {
34844
+ return dirPath.replaceAll(/[\\*?[\]{}()!]/g, "\\$&");
34845
+ }
34846
+ /**
34847
+ * Claude Code scopes a nested skill by its location: a skill living in
34848
+ * `apps/web/.claude/skills/deploy` only activates while working under
34849
+ * `apps/web`. rulesync generates every imported skill into the project-root
34850
+ * `.claude/skills/`, so on import that location-based scoping has to be
34851
+ * re-expressed as an explicit `paths` glob — otherwise the round-trip silently
34852
+ * promotes a subtree skill to global activation.
34853
+ *
34854
+ * Returns the derived glob for a nested discovery root, or `undefined` for the
34855
+ * project-root `.claude/skills` (and for any root whose subtree cannot be
34856
+ * determined), where no scoping is implied.
34857
+ *
34858
+ * @see https://code.claude.com/docs/en/skills
34859
+ */
34860
+ function deriveNestedSkillPaths(relativeDirPath) {
34861
+ const posixDirPath = toPosixPath(relativeDirPath);
34862
+ const skillsDirSuffix = `/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`;
34863
+ if (!posixDirPath.endsWith(skillsDirSuffix)) return;
34864
+ const subtree = posixDirPath.slice(0, -skillsDirSuffix.length);
34865
+ if (subtree === "" || subtree === ".") return;
34866
+ return [`${escapeGlobLiteral(subtree)}/**`];
34867
+ }
34868
+ /**
33955
34869
  * Represents a Claude Code skill directory.
33956
34870
  * Unlike subagents and commands, skills are directories containing SKILL.md and other files.
33957
34871
  * Extends ToolSkill to inherit directory management and security features from AiDir.
@@ -34039,6 +34953,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34039
34953
  }
34040
34954
  toRulesyncSkill() {
34041
34955
  const frontmatter = this.getFrontmatter();
34956
+ const resolvedPaths = frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath);
34042
34957
  const claudecodeSection = {
34043
34958
  ...frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use },
34044
34959
  ...frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] },
@@ -34055,7 +34970,7 @@ var ClaudecodeSkill = class extends ToolSkill {
34055
34970
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34056
34971
  ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34057
34972
  ...this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true },
34058
- ...frontmatter.paths !== void 0 && { paths: frontmatter.paths }
34973
+ ...resolvedPaths !== void 0 && { paths: resolvedPaths }
34059
34974
  };
34060
34975
  const rulesyncFrontmatter = {
34061
34976
  name: frontmatter.name,
@@ -34453,7 +35368,8 @@ var CodexCliSkill = class CodexCliSkill extends ToolSkill {
34453
35368
  fileBuffer: Buffer.from(dump(openaiObject, {
34454
35369
  lineWidth: -1,
34455
35370
  noRefs: true
34456
- }))
35371
+ })),
35372
+ composed: true
34457
35373
  }] : baseOtherFiles;
34458
35374
  return new CodexCliSkill({
34459
35375
  outputRoot,
@@ -34513,7 +35429,11 @@ const CopilotSkillFrontmatterSchema = z.looseObject({
34513
35429
  name: z.string(),
34514
35430
  description: z.string(),
34515
35431
  license: z.optional(z.string()),
34516
- "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
35432
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
35433
+ "argument-hint": z.optional(z.string()),
35434
+ "user-invocable": z.optional(z.boolean()),
35435
+ "disable-model-invocation": z.optional(z.boolean()),
35436
+ context: z.optional(z.string())
34517
35437
  });
34518
35438
  /**
34519
35439
  * Represents a GitHub Copilot skill directory.
@@ -34568,14 +35488,10 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34568
35488
  };
34569
35489
  }
34570
35490
  toRulesyncSkill() {
34571
- const frontmatter = this.getFrontmatter();
34572
- const copilotSection = {
34573
- ...frontmatter.license !== void 0 && { license: frontmatter.license },
34574
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
34575
- };
35491
+ const { name, description, ...copilotSection } = this.getFrontmatter();
34576
35492
  const rulesyncFrontmatter = {
34577
- name: frontmatter.name,
34578
- description: frontmatter.description,
35493
+ name,
35494
+ description,
34579
35495
  targets: ["*"],
34580
35496
  ...Object.keys(copilotSection).length > 0 && { copilot: copilotSection }
34581
35497
  };
@@ -34593,11 +35509,22 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
34593
35509
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34594
35510
  const settablePaths = CopilotSkill.getSettablePaths({ global });
34595
35511
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35512
+ const copilotSection = rulesyncFrontmatter.copilot;
35513
+ const resolvedUserInvocable = resolveUserInvocable({
35514
+ rootFrontmatter: rulesyncFrontmatter,
35515
+ section: copilotSection
35516
+ });
35517
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35518
+ rootFrontmatter: rulesyncFrontmatter,
35519
+ section: copilotSection
35520
+ });
35521
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotFields } = copilotSection ?? {};
34596
35522
  const copilotFrontmatter = {
35523
+ ...copilotFields,
34597
35524
  name: rulesyncFrontmatter.name,
34598
35525
  description: rulesyncFrontmatter.description,
34599
- ...rulesyncFrontmatter.copilot?.license !== void 0 && { license: rulesyncFrontmatter.copilot.license },
34600
- ...rulesyncFrontmatter.copilot?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilot["allowed-tools"] }
35526
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35527
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
34601
35528
  };
34602
35529
  return new CopilotSkill({
34603
35530
  outputRoot,
@@ -34716,17 +35643,10 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
34716
35643
  };
34717
35644
  }
34718
35645
  toRulesyncSkill() {
34719
- const frontmatter = this.getFrontmatter();
34720
- const copilotcliSection = {
34721
- ...frontmatter.license !== void 0 && { license: frontmatter.license },
34722
- ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] },
34723
- ...frontmatter["argument-hint"] !== void 0 && { "argument-hint": frontmatter["argument-hint"] },
34724
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34725
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] }
34726
- };
35646
+ const { name, description, ...copilotcliSection } = this.getFrontmatter();
34727
35647
  const rulesyncFrontmatter = {
34728
- name: frontmatter.name,
34729
- description: frontmatter.description,
35648
+ name,
35649
+ description,
34730
35650
  targets: ["*"],
34731
35651
  ...Object.keys(copilotcliSection).length > 0 && { copilotcli: copilotcliSection }
34732
35652
  };
@@ -34744,14 +35664,22 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
34744
35664
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
34745
35665
  const settablePaths = CopilotcliSkill.getSettablePaths({ global });
34746
35666
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
35667
+ const copilotcliSection = rulesyncFrontmatter.copilotcli;
35668
+ const resolvedUserInvocable = resolveUserInvocable({
35669
+ rootFrontmatter: rulesyncFrontmatter,
35670
+ section: copilotcliSection
35671
+ });
35672
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35673
+ rootFrontmatter: rulesyncFrontmatter,
35674
+ section: copilotcliSection
35675
+ });
35676
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotcliFields } = copilotcliSection ?? {};
34747
35677
  const copilotcliFrontmatter = {
35678
+ ...copilotcliFields,
34748
35679
  name: rulesyncFrontmatter.name,
34749
35680
  description: rulesyncFrontmatter.description,
34750
- ...rulesyncFrontmatter.copilotcli?.license !== void 0 && { license: rulesyncFrontmatter.copilotcli.license },
34751
- ...rulesyncFrontmatter.copilotcli?.["allowed-tools"] !== void 0 && { "allowed-tools": rulesyncFrontmatter.copilotcli["allowed-tools"] },
34752
- ...rulesyncFrontmatter.copilotcli?.["argument-hint"] !== void 0 && { "argument-hint": rulesyncFrontmatter.copilotcli["argument-hint"] },
34753
- ...rulesyncFrontmatter.copilotcli?.["user-invocable"] !== void 0 && { "user-invocable": rulesyncFrontmatter.copilotcli["user-invocable"] },
34754
- ...rulesyncFrontmatter.copilotcli?.["disable-model-invocation"] !== void 0 && { "disable-model-invocation": rulesyncFrontmatter.copilotcli["disable-model-invocation"] }
35681
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
35682
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
34755
35683
  };
34756
35684
  return new CopilotcliSkill({
34757
35685
  outputRoot,
@@ -35309,7 +36237,9 @@ const FactorydroidSkillFrontmatterSchema = z.looseObject({
35309
36237
  name: z.string(),
35310
36238
  description: z.string(),
35311
36239
  "user-invocable": z.optional(z.boolean()),
35312
- "disable-model-invocation": z.optional(z.boolean())
36240
+ "disable-model-invocation": z.optional(z.boolean()),
36241
+ enabled: z.optional(z.boolean()),
36242
+ "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
35313
36243
  });
35314
36244
  /**
35315
36245
  * Represents a Factory Droid skill directory.
@@ -35365,7 +36295,9 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35365
36295
  const frontmatter = this.getFrontmatter();
35366
36296
  const factorydroidBlock = {
35367
36297
  ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
35368
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] }
36298
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
36299
+ ...frontmatter.enabled !== void 0 && { enabled: frontmatter.enabled },
36300
+ ...frontmatter["allowed-tools"] !== void 0 && { "allowed-tools": frontmatter["allowed-tools"] }
35369
36301
  };
35370
36302
  const rulesyncFrontmatter = {
35371
36303
  name: frontmatter.name,
@@ -35387,19 +36319,22 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
35387
36319
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
35388
36320
  const settablePaths = FactorydroidSkill.getSettablePaths({ global });
35389
36321
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
36322
+ const factorydroidSection = rulesyncFrontmatter.factorydroid;
35390
36323
  const resolvedDisableModelInvocation = resolveDisableModelInvocation({
35391
36324
  rootFrontmatter: rulesyncFrontmatter,
35392
- section: rulesyncFrontmatter.factorydroid
36325
+ section: factorydroidSection
35393
36326
  });
35394
36327
  const resolvedUserInvocable = resolveUserInvocable({
35395
36328
  rootFrontmatter: rulesyncFrontmatter,
35396
- section: rulesyncFrontmatter.factorydroid
36329
+ section: factorydroidSection
35397
36330
  });
35398
36331
  const factorydroidFrontmatter = {
35399
36332
  name: rulesyncFrontmatter.name,
35400
36333
  description: rulesyncFrontmatter.description,
35401
36334
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
35402
- ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
36335
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
36336
+ ...factorydroidSection?.enabled !== void 0 && { enabled: factorydroidSection.enabled },
36337
+ ...factorydroidSection?.["allowed-tools"] !== void 0 && { "allowed-tools": factorydroidSection["allowed-tools"] }
35403
36338
  };
35404
36339
  return new FactorydroidSkill({
35405
36340
  outputRoot,
@@ -36216,7 +37151,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
36216
37151
  //#region src/features/skills/kiro-skill.ts
36217
37152
  const KiroSkillFrontmatterSchema = z.looseObject({
36218
37153
  name: z.string(),
36219
- description: z.string()
37154
+ description: z.string(),
37155
+ license: z.optional(z.string()),
37156
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
37157
+ metadata: z.optional(z.looseObject({}))
36220
37158
  });
36221
37159
  /**
36222
37160
  * Represents a Kiro skill directory.
@@ -36270,11 +37208,12 @@ var KiroSkill = class KiroSkill extends ToolSkill {
36270
37208
  };
36271
37209
  }
36272
37210
  toRulesyncSkill() {
36273
- const frontmatter = this.getFrontmatter();
37211
+ const { name, description, ...kiroSection } = this.getFrontmatter();
36274
37212
  const rulesyncFrontmatter = {
36275
- name: frontmatter.name,
36276
- description: frontmatter.description,
36277
- targets: ["*"]
37213
+ name,
37214
+ description,
37215
+ targets: ["*"],
37216
+ ...Object.keys(kiroSection).length > 0 && { kiro: kiroSection }
36278
37217
  };
36279
37218
  return new RulesyncSkill({
36280
37219
  outputRoot: this.outputRoot,
@@ -36291,6 +37230,7 @@ var KiroSkill = class KiroSkill extends ToolSkill {
36291
37230
  const settablePaths = KiroSkill.getSettablePaths({ global });
36292
37231
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
36293
37232
  const kiroFrontmatter = {
37233
+ ...rulesyncFrontmatter.kiro,
36294
37234
  name: rulesyncFrontmatter.name,
36295
37235
  description: rulesyncFrontmatter.description
36296
37236
  };
@@ -39420,7 +40360,17 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39420
40360
  validate: true
39421
40361
  });
39422
40362
  }
39423
- static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
40363
+ /**
40364
+ * Last chance to adjust the tool frontmatter before it is written. The base
40365
+ * implementation only warns about names Claude Code rejects; plugin-scoped
40366
+ * subclasses extend it to drop fields Claude Code refuses to honor for
40367
+ * plugin-shipped agents.
40368
+ */
40369
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
40370
+ 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.`);
40371
+ return frontmatter;
40372
+ }
40373
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
39424
40374
  const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
39425
40375
  const claudecodeSection = this.filterToolSpecificSection(rulesyncFrontmatter.claudecode ?? {}, ["name", "description"]);
39426
40376
  const rawClaudecodeFrontmatter = {
@@ -39430,7 +40380,11 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39430
40380
  };
39431
40381
  const result = ClaudecodeSubagentFrontmatterSchema.safeParse(rawClaudecodeFrontmatter);
39432
40382
  if (!result.success) throw new Error(`Invalid claudecode subagent frontmatter in ${rulesyncSubagent.getRelativeFilePath()}: ${formatError(result.error)}`);
39433
- const claudecodeFrontmatter = result.data;
40383
+ const claudecodeFrontmatter = this.sanitizeFrontmatter({
40384
+ frontmatter: result.data,
40385
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
40386
+ logger
40387
+ });
39434
40388
  const body = rulesyncSubagent.getBody();
39435
40389
  const fileContent = stringifyFrontmatter(body, claudecodeFrontmatter);
39436
40390
  const paths = this.getSettablePaths({ global });
@@ -39499,6 +40453,21 @@ var ClaudecodeSubagent = class ClaudecodeSubagent extends ToolSubagent {
39499
40453
  };
39500
40454
  //#endregion
39501
40455
  //#region src/features/subagents/claudecode-plugin-subagent.ts
40456
+ /**
40457
+ * Claude Code refuses these for plugin-shipped agents "for security reasons",
40458
+ * so emitting them leaves the author believing the agent is constrained when it
40459
+ * is not. Only these three are dropped: the other fields upstream does not list
40460
+ * (e.g. `color`) are merely ignored, with no misleading security posture.
40461
+ *
40462
+ * @see https://code.claude.com/docs/en/plugins-reference
40463
+ */
40464
+ const PLUGIN_FORBIDDEN_FIELDS = [
40465
+ "hooks",
40466
+ "mcpServers",
40467
+ "permissionMode"
40468
+ ];
40469
+ /** The only `isolation` value plugin agents accept. */
40470
+ const PLUGIN_ISOLATION_VALUE = "worktree";
39502
40471
  var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39503
40472
  static isTargetedByRulesyncSubagent(rulesyncSubagent) {
39504
40473
  const targets = rulesyncSubagent.getFrontmatter().targets;
@@ -39507,6 +40476,21 @@ var ClaudecodePluginSubagent = class extends ClaudecodeSubagent {
39507
40476
  static getSettablePaths() {
39508
40477
  return { relativeDirPath: CLAUDECODE_PLUGIN_AGENTS_DIR };
39509
40478
  }
40479
+ static sanitizeFrontmatter({ frontmatter, relativeFilePath, logger }) {
40480
+ const sanitized = { ...super.sanitizeFrontmatter({
40481
+ frontmatter,
40482
+ relativeFilePath,
40483
+ logger
40484
+ }) };
40485
+ const dropped = PLUGIN_FORBIDDEN_FIELDS.filter((field) => sanitized[field] !== void 0);
40486
+ for (const field of PLUGIN_FORBIDDEN_FIELDS) delete sanitized[field];
40487
+ 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.`);
40488
+ if (sanitized.isolation !== void 0 && sanitized.isolation !== PLUGIN_ISOLATION_VALUE) {
40489
+ 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.`);
40490
+ delete sanitized.isolation;
40491
+ }
40492
+ return sanitized;
40493
+ }
39510
40494
  };
39511
40495
  //#endregion
39512
40496
  //#region src/features/subagents/cline-subagent.ts
@@ -42815,14 +43799,14 @@ var ZoocodeSubagent = class extends RooSubagent {
42815
43799
  static toRooMode(rulesyncSubagent) {
42816
43800
  const mode = super.toRooMode(rulesyncSubagent);
42817
43801
  const frontmatter = rulesyncSubagent.getFrontmatter();
42818
- const zoocodeSection = isRecord(frontmatter.zoocode) ? frontmatter.zoocode : {};
43802
+ const zoocodeSection = isRecord$1(frontmatter.zoocode) ? frontmatter.zoocode : {};
42819
43803
  if (isStringArray$1(zoocodeSection.allowedMcpServers)) mode.allowedMcpServers = zoocodeSection.allowedMcpServers;
42820
43804
  return mode;
42821
43805
  }
42822
43806
  toRulesyncSubagents() {
42823
43807
  return super.toRulesyncSubagents().map((subagent) => {
42824
43808
  const frontmatter = subagent.getFrontmatter();
42825
- const { allowedMcpServers, ...restRooSection } = isRecord(frontmatter.roo) ? { ...frontmatter.roo } : {};
43809
+ const { allowedMcpServers, ...restRooSection } = isRecord$1(frontmatter.roo) ? { ...frontmatter.roo } : {};
42826
43810
  const rebuilt = {
42827
43811
  ...frontmatter,
42828
43812
  targets: ["zoocode"],
@@ -43160,7 +44144,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
43160
44144
  outputRoot: this.outputRoot,
43161
44145
  relativeDirPath: RulesyncSubagent.getSettablePaths().relativeDirPath,
43162
44146
  rulesyncSubagent,
43163
- global: this.global
44147
+ global: this.global,
44148
+ logger: this.logger
43164
44149
  }));
43165
44150
  }
43166
44151
  async convertToolFilesToRulesyncFiles(toolFiles) {
@@ -45113,7 +46098,7 @@ var CodexcliRule = class CodexcliRule extends ToolRule {
45113
46098
  };
45114
46099
  //#endregion
45115
46100
  //#region src/features/rules/copilot-rule.ts
45116
- const CopilotRuleFrontmatterSchema = z.object({
46101
+ const CopilotRuleFrontmatterSchema = z.looseObject({
45117
46102
  description: z.optional(z.string()),
45118
46103
  applyTo: z.optional(z.string()),
45119
46104
  name: z.optional(z.string()),
@@ -45169,15 +46154,13 @@ var CopilotRule = class CopilotRule extends ToolRule {
45169
46154
  toRulesyncRule() {
45170
46155
  let globs;
45171
46156
  if (this.frontmatter.applyTo) globs = this.frontmatter.applyTo.split(",").map((g) => g.trim());
46157
+ const { description, applyTo: _applyTo, ...copilotFields } = this.frontmatter;
45172
46158
  const rulesyncFrontmatter = {
45173
46159
  targets: ["*"],
45174
46160
  root: this.isRoot(),
45175
- description: this.frontmatter.description,
46161
+ description,
45176
46162
  globs,
45177
- ...(this.frontmatter.excludeAgent || this.frontmatter.name) && { copilot: {
45178
- ...this.frontmatter.excludeAgent && { excludeAgent: this.frontmatter.excludeAgent },
45179
- ...this.frontmatter.name && { name: this.frontmatter.name }
45180
- } }
46163
+ ...Object.keys(copilotFields).length > 0 && { copilot: copilotFields }
45181
46164
  };
45182
46165
  const relativeFilePath = this.getRelativeFilePath().replace(/\.instructions\.md$/, ".md");
45183
46166
  return new RulesyncRule({
@@ -45194,10 +46177,9 @@ var CopilotRule = class CopilotRule extends ToolRule {
45194
46177
  const root = rulesyncFrontmatter.root;
45195
46178
  const paths = this.getSettablePaths({ global });
45196
46179
  const copilotFrontmatter = {
46180
+ ...rulesyncFrontmatter.copilot,
45197
46181
  description: rulesyncFrontmatter.description,
45198
- applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0,
45199
- excludeAgent: rulesyncFrontmatter.copilot?.excludeAgent,
45200
- name: rulesyncFrontmatter.copilot?.name
46182
+ applyTo: rulesyncFrontmatter.globs?.length ? rulesyncFrontmatter.globs.join(",") : void 0
45201
46183
  };
45202
46184
  const body = rulesyncRule.getBody();
45203
46185
  if (root) return new CopilotRule({
@@ -50894,6 +51876,6 @@ async function importChecksCore(params) {
50894
51876
  return writtenCount;
50895
51877
  }
50896
51878
  //#endregion
50897
- export { JsonLogger as $, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as $t, RulesyncRuleFrontmatterSchema as A, PACKAGING_TOOL_TARGETS as At, RulesyncCheck as B, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeTempDirectory as Ct, RulesyncSkill as D, writeFileContent as Dt, RulesyncSubagentFrontmatterSchema as E, toPosixPath as Et, getRulesyncSourceCandidates as F, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Ft, SKILL_FILE_NAME as G, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Gt, stringifyFrontmatter as H, RULESYNC_HOOKS_LEGACY_FILE_NAME as Ht, resolveRulesyncSourceWritePath as I, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as It, ConfigFileSchema as J, RULESYNC_MCP_RELATIVE_FILE_PATH as Jt, ConfigResolver as K, RULESYNC_MCP_FILE_NAME as Kt, parseJsonc as L, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Lt, RulesyncMcp as M, MAX_FILE_SIZE as Mt, RulesyncIgnore as N, RULESYNC_AIIGNORE_FILE_NAME as Nt, RulesyncSkillFrontmatterSchema as O, ALL_TOOL_TARGETS as Ot, RulesyncHooks as P, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Pt, ConsoleLogger as Q, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as Qt, RulesyncCommand as R, RULESYNC_CONFIG_SCHEMA_URL as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeFileStrict as St, RulesyncSubagent as T, runWithDirectoryRollback as Tt, loadYaml as U, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_HOOKS_FILE_NAME as Vt, SHARED_USER_MANAGED_CONFIG_PATHS as W, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Wt, SourceEntrySchema as X, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Xt, GITIGNORE_DESTINATION_KEY as Y, RULESYNC_MCP_SCHEMA_URL as Yt, findControlCharacter as Z, RULESYNC_PERMISSIONS_FILE_NAME as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, readFileContent as _t, convertFromTool as a, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as an, assertTreeContainsNoSymlinks as at, CLAUDECODE_SKILLS_DIR_PATH as b, removeDirectoryStrict as bt, SubagentsProcessor as c, DEPRECATED_FEATURE_REPLACEMENTS as cn, createTempDirectory as ct, IgnoreProcessor as d, fileExists as dt, RULESYNC_PERMISSIONS_SCHEMA_URL as en, fallbackLogger as et, HooksProcessor as f, findFilesByGlobs as ft, CLAUDECODE_DIR as g, listDirectoryFiles as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, isSymlink as ht, getProcessorRegistryEntry as i, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as in, assertDirectoryIfExists as it, RulesyncPermissions as j, ToolTargetSchema as jt, RulesyncRule as k, ALL_TOOL_TARGETS_WITH_WILDCARD as kt, SkillsProcessor as l, formatError as ln, directoryExists as lt, QWENCODE_DIR as m, getHomeDirectory as mt, checkRulesyncDirExists as n, RULESYNC_RULES_RELATIVE_DIR_PATH as nn, CLIError as nt, isPackagingToolTarget as o, ALL_FEATURES as on, assertWritablePathInsideRoot as ot, CommandsProcessor as p, getFileSize as pt, CONFLICTING_TARGET_PAIRS as q, RULESYNC_MCP_LEGACY_FILE_NAME as qt, generate as r, RULESYNC_SKILLS_RELATIVE_DIR_PATH as rn, ErrorCodes as rt, RulesProcessor as s, ALL_FEATURES_WITH_WILDCARD as sn, checkPathTraversal as st, importFromTool as t, RULESYNC_RELATIVE_DIR_PATH as tn, warnOnConflictingFlags as tt, McpProcessor as u, ensureDir as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, readFileContentOrNull as vt, getLocalSkillDirNames as w, resolvePath as wt, ChecksProcessor as x, removeFile as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, removeDirectory as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as zt };
51879
+ export { JsonLogger as $, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, ALL_TOOL_TARGETS_WITH_WILDCARD as At, RulesyncCheck as B, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeTempDirectory as Ct, RulesyncSkill as D, writeFileBuffer as Dt, RulesyncSubagentFrontmatterSchema as E, toPosixPath as Et, getRulesyncSourceCandidates as F, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Ft, SKILL_FILE_NAME as G, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Gt, stringifyFrontmatter as H, RULESYNC_HOOKS_FILE_NAME as Ht, resolveRulesyncSourceWritePath as I, RULESYNC_CHECKS_RELATIVE_DIR_PATH as It, ConfigFileSchema as J, RULESYNC_MCP_LEGACY_FILE_NAME as Jt, ConfigResolver as K, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Kt, parseJsonc as L, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Lt, RulesyncMcp as M, ToolTargetSchema as Mt, RulesyncIgnore as N, MAX_FILE_SIZE as Nt, RulesyncSkillFrontmatterSchema as O, writeFileContent as Ot, RulesyncHooks as P, RULESYNC_AIIGNORE_FILE_NAME as Pt, ConsoleLogger as Q, RULESYNC_PERMISSIONS_FILE_NAME as Qt, RulesyncCommand as R, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeFileStrict as St, RulesyncSubagent as T, runWithDirectoryRollback as Tt, loadYaml as U, RULESYNC_HOOKS_LEGACY_FILE_NAME as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Vt, SHARED_USER_MANAGED_CONFIG_PATHS as W, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Wt, SourceEntrySchema as X, RULESYNC_MCP_SCHEMA_URL as Xt, GITIGNORE_DESTINATION_KEY as Y, RULESYNC_MCP_RELATIVE_FILE_PATH as Yt, findControlCharacter as Z, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, readFileContent as _t, convertFromTool as a, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as an, assertTreeContainsNoSymlinks as at, CLAUDECODE_SKILLS_DIR_PATH as b, removeDirectoryStrict as bt, SubagentsProcessor as c, ALL_FEATURES_WITH_WILDCARD as cn, createTempDirectory as ct, IgnoreProcessor as d, fileExists as dt, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as en, fallbackLogger as et, HooksProcessor as f, findFilesByGlobs as ft, CLAUDECODE_DIR as g, listDirectoryFiles as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, isSymlink as ht, getProcessorRegistryEntry as i, RULESYNC_SKILLS_RELATIVE_DIR_PATH as in, assertDirectoryIfExists as it, RulesyncPermissions as j, PACKAGING_TOOL_TARGETS as jt, RulesyncRule as k, ALL_TOOL_TARGETS as kt, SkillsProcessor as l, DEPRECATED_FEATURE_REPLACEMENTS as ln, directoryExists as lt, QWENCODE_DIR as m, getHomeDirectory as mt, checkRulesyncDirExists as n, RULESYNC_RELATIVE_DIR_PATH as nn, CLIError as nt, isPackagingToolTarget as o, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as on, assertWritablePathInsideRoot as ot, CommandsProcessor as p, getFileSize as pt, CONFLICTING_TARGET_PAIRS as q, RULESYNC_MCP_FILE_NAME as qt, generate as r, RULESYNC_RULES_RELATIVE_DIR_PATH as rn, ErrorCodes as rt, RulesProcessor as s, ALL_FEATURES as sn, checkPathTraversal as st, importFromTool as t, RULESYNC_PERMISSIONS_SCHEMA_URL as tn, warnOnConflictingFlags as tt, McpProcessor as u, formatError as un, ensureDir as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, readFileContentOrNull as vt, getLocalSkillDirNames as w, resolvePath as wt, ChecksProcessor as x, removeFile as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, removeDirectory as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_CONFIG_SCHEMA_URL as zt };
50898
51880
 
50899
- //# sourceMappingURL=import-BlwypG9v.js.map
51881
+ //# sourceMappingURL=import-KXnvmbzr.js.map