rulesync 16.8.0 → 16.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -382,6 +382,7 @@ const skillsProcessorToolTargetTuple = [
382
382
  ];
383
383
  const hooksProcessorToolTargetTuple = [
384
384
  "amp",
385
+ "cline",
385
386
  "antigravity-cli",
386
387
  "antigravity-ide",
387
388
  "antigravity-plugin",
@@ -657,6 +658,29 @@ async function writeFileContent(filepath, content) {
657
658
  await ensureDir((0, node_path.dirname)(filepath));
658
659
  await (0, node_fs_promises.writeFile)(filepath, content, "utf-8");
659
660
  }
661
+ /**
662
+ * Apply a POSIX mode to an existing file. Windows has no executable bit and
663
+ * `chmod` there only toggles the read-only flag, so the call is skipped rather
664
+ * than writing a mode the platform cannot honor.
665
+ */
666
+ async function applyFileMode(filepath, mode) {
667
+ if (process.platform === "win32") return;
668
+ await (0, node_fs_promises.chmod)(filepath, mode);
669
+ }
670
+ /**
671
+ * Restore an executable bit that went missing (interrupted run, a copy that
672
+ * dropped the mode). A file whose mode is merely stricter than `mode` — the
673
+ * user chose 0700 over 0755 — is left alone.
674
+ */
675
+ async function restoreMissingExecutableBit(filepath, mode) {
676
+ if (process.platform === "win32") return;
677
+ try {
678
+ if (((await (0, node_fs_promises.stat)(filepath)).mode & 73) !== 0) return;
679
+ } catch {
680
+ return;
681
+ }
682
+ await (0, node_fs_promises.chmod)(filepath, mode);
683
+ }
660
684
  async function writeFileBuffer(filepath, buffer) {
661
685
  await ensureDir((0, node_path.dirname)(filepath));
662
686
  await (0, node_fs_promises.writeFile)(filepath, buffer);
@@ -1826,6 +1850,13 @@ var AiFile = class {
1826
1850
  return false;
1827
1851
  }
1828
1852
  /**
1853
+ * POSIX mode to apply after writing, or `undefined` to leave the default
1854
+ * alone. Override in subclasses whose output the tool executes directly
1855
+ * (e.g. Cline's hook scripts, which are spawned by path and therefore need
1856
+ * the executable bit).
1857
+ */
1858
+ getFileMode() {}
1859
+ /**
1829
1860
  * Returns whether this file can be deleted by rulesync.
1830
1861
  * Override in subclasses that should not be deleted (e.g., user-managed config files).
1831
1862
  */
@@ -2269,7 +2300,8 @@ const HOOK_EVENTS = [
2269
2300
  "fileChanged",
2270
2301
  "directoryAdded",
2271
2302
  "elicitation",
2272
- "elicitationResult"
2303
+ "elicitationResult",
2304
+ "sessionDelete"
2273
2305
  ];
2274
2306
  /** Hook events supported by Cursor. */
2275
2307
  const CURSOR_HOOK_EVENTS = [
@@ -2365,9 +2397,10 @@ const DEVIN_HOOK_EVENTS = [
2365
2397
  /**
2366
2398
  * Hook events supported by OpenCode.
2367
2399
  *
2368
- * `preCompact` maps to `experimental.session.compacting`, which the plugin docs
2369
- * document as a named `(input, output)` hook rather than an `event.type`
2370
- * dispatch; the other entries are all generic events.
2400
+ * `preCompact` maps to `experimental.session.compacting` and
2401
+ * `beforeSubmitPrompt` to `chat.message`, both of which the plugin docs
2402
+ * document as named `(input, output)` hooks rather than `event.type`
2403
+ * dispatches; the other entries are all generic events.
2371
2404
  *
2372
2405
  * @see https://opencode.ai/docs/plugins/
2373
2406
  */
@@ -2383,16 +2416,22 @@ const OPENCODE_HOOK_EVENTS = [
2383
2416
  "preCompact",
2384
2417
  "postCompact",
2385
2418
  "afterError",
2386
- "fileChanged"
2419
+ "fileChanged",
2420
+ "notification",
2421
+ "permissionDenied",
2422
+ "beforeSubmitPrompt"
2387
2423
  ];
2388
2424
  /**
2389
- * Hook events supported by Kilo. Identical to OpenCode: Kilo's plugin docs list
2390
- * the same event surface, including `session.compacted`, `session.error`,
2391
- * `file.watcher.updated` and the experimental compaction hook.
2425
+ * Hook events supported by Kilo. Kilo's plugin docs list the same event surface
2426
+ * as OpenCode's including `session.compacted`, `session.error`,
2427
+ * `file.watcher.updated`, `permission.replied`, `chat.message` and the
2428
+ * experimental compaction hook — with one exception: they document no TUI
2429
+ * events at all, so `tui.toast.show` (canonical `notification`) is left out
2430
+ * rather than emitted into a plugin where it may never fire.
2392
2431
  *
2393
2432
  * @see https://kilo.ai/docs/automate/extending/plugins
2394
2433
  */
2395
- const KILO_HOOK_EVENTS = OPENCODE_HOOK_EVENTS;
2434
+ const KILO_HOOK_EVENTS = OPENCODE_HOOK_EVENTS.filter((event) => event !== "notification");
2396
2435
  /**
2397
2436
  * Hook events supported by Pi Coding Agent, bridged through a generated
2398
2437
  * TypeScript extension (Pi has no static hook config file; its extension API
@@ -2430,6 +2469,29 @@ const AMP_HOOK_EVENTS = [
2430
2469
  "stop"
2431
2470
  ];
2432
2471
  /**
2472
+ * Hook events supported by Cline's file-based hooks. Cline resolves one
2473
+ * executable per lifecycle event from its hooks directory, and the event names
2474
+ * it accepts are fixed by `VALID_HOOK_TYPES` in
2475
+ * `apps/vscode/src/core/hooks/utils.ts`: `TaskStart`, `TaskResume`,
2476
+ * `TaskCancel`, `TaskComplete`, `PreToolUse`, `PostToolUse`,
2477
+ * `UserPromptSubmit`, `Notification` and `PreCompact`.
2478
+ *
2479
+ * `TaskResume` and `TaskCancel` have no canonical counterpart and stay
2480
+ * unmapped rather than being approximated by `sessionEnd` / `stop`, whose
2481
+ * semantics differ.
2482
+ *
2483
+ * @see https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts
2484
+ */
2485
+ const CLINE_HOOK_EVENTS = [
2486
+ "sessionStart",
2487
+ "preToolUse",
2488
+ "postToolUse",
2489
+ "beforeSubmitPrompt",
2490
+ "preCompact",
2491
+ "notification",
2492
+ "taskCompleted"
2493
+ ];
2494
+ /**
2433
2495
  * Hook events supported by GitHub Copilot (cloud coding agent).
2434
2496
  *
2435
2497
  * GitHub now documents an eight-event surface for `.github/hooks/*.json`:
@@ -2567,7 +2629,11 @@ const GOOSE_HOOK_EVENTS = [
2567
2629
  "beforeShellExecution",
2568
2630
  "afterShellExecution"
2569
2631
  ];
2570
- /** Hook events supported by Kiro CLI. */
2632
+ /**
2633
+ * Hook events supported by the embedded agent-config hook format, which only
2634
+ * the deprecated `kiro` alias still writes. See {@link KIRO_IDE_HOOK_EVENTS}
2635
+ * for the standalone format both Kiro products read today.
2636
+ */
2571
2637
  const KIRO_HOOK_EVENTS = [
2572
2638
  "sessionStart",
2573
2639
  "sessionEnd",
@@ -2577,15 +2643,17 @@ const KIRO_HOOK_EVENTS = [
2577
2643
  "stop"
2578
2644
  ];
2579
2645
  /**
2580
- * Hook events supported by the Kiro IDE (`.kiro/hooks/*.json` v1).
2581
- *
2582
- * Kiro IDE 1.0 exposes PascalCase triggers. rulesync maps the canonical
2583
- * lifecycle events that have a clean 1:1 IDE equivalent: `SessionStart`,
2584
- * `Stop`, `UserPromptSubmit`, `PreToolUse`, and `PostToolUse`. The IDE also
2585
- * documents file-event (`PostFileCreate`/`PostFileSave`/`PostFileDelete`) and
2586
- * spec-task (`PreTaskExec`/`PostTaskExec`) triggers that have no canonical
2587
- * equivalent; those can still be emitted verbatim via a `kiro-ide` override
2588
- * block (unknown event keys pass through unchanged).
2646
+ * Hook events supported by Kiro's standalone hooks format
2647
+ * (`.kiro/hooks/*.json` v1), which the Kiro IDE and Kiro CLI 3.0 both read.
2648
+ *
2649
+ * Kiro exposes PascalCase triggers. rulesync maps the canonical lifecycle
2650
+ * events that have a clean 1:1 equivalent: `SessionStart`, `Stop`,
2651
+ * `UserPromptSubmit`, `PreToolUse`, and `PostToolUse`. Kiro also documents
2652
+ * file-event (`PostFileCreate`/`PostFileSave`/`PostFileDelete`) and spec-task
2653
+ * (`PreTaskExec`/`PostTaskExec`) triggers that have no canonical equivalent;
2654
+ * those can still be emitted verbatim via a `kiro-ide` or `kiro-cli` override
2655
+ * block (unknown event keys pass through unchanged). There is no `SessionEnd`
2656
+ * trigger, so the canonical `sessionEnd` has no home here.
2589
2657
  * @see https://kiro.dev/docs/hooks/types/
2590
2658
  */
2591
2659
  const KIRO_IDE_HOOK_EVENTS = [
@@ -2707,7 +2775,8 @@ const QWENCODE_HOOK_EVENTS = [
2707
2775
  "instructionsLoaded",
2708
2776
  "todoCreated",
2709
2777
  "todoCompleted",
2710
- "messageDisplay"
2778
+ "messageDisplay",
2779
+ "sessionDelete"
2711
2780
  ];
2712
2781
  /**
2713
2782
  * Hook events supported by Reasonix.
@@ -2770,7 +2839,11 @@ const GROKCLI_HOOK_EVENTS = [
2770
2839
  /**
2771
2840
  * Hook events supported by Kimi Code.
2772
2841
  *
2773
- * Kimi Code also exposes `Interrupt`, which has no canonical rulesync event.
2842
+ * Kimi Code also exposes `PermissionResult`, `Interrupt`, and the four events
2843
+ * added in 0.32.0 (`TurnStarted`, `UserPromptQueued`, `TaskStarted`,
2844
+ * `SessionHeartbeat`), none of which have a canonical rulesync event. They are
2845
+ * listed in `KIMI_CODE_NATIVE_HOOK_EVENTS` so a per-tool `kimi-code` override
2846
+ * can address them by their native name.
2774
2847
  *
2775
2848
  * @see https://moonshotai.github.io/kimi-code/en/customization/hooks.html
2776
2849
  */
@@ -2806,10 +2879,27 @@ const CANONICAL_TO_KIMI_CODE_EVENT_NAMES = {
2806
2879
  preCompact: "PreCompact",
2807
2880
  postCompact: "PostCompact"
2808
2881
  };
2882
+ /**
2883
+ * Every event name Kimi Code accepts in a `[[hooks]]` entry: the ones with a
2884
+ * canonical rulesync counterpart plus the native-only ones, which are reachable
2885
+ * through a per-tool `kimi-code` override that names them directly.
2886
+ *
2887
+ * `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` were
2888
+ * added in Kimi Code 0.32.0. They stay native-only: `TaskStarted` fires when a
2889
+ * background task starts and matches on task kind, whereas the canonical
2890
+ * `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated`
2891
+ * (fired while a task is being created), so the two are not interchangeable.
2892
+ *
2893
+ * @see https://moonshotai.github.io/kimi-code/en/customization/hooks.html
2894
+ */
2809
2895
  const KIMI_CODE_NATIVE_HOOK_EVENTS = [
2810
2896
  ...Object.values(CANONICAL_TO_KIMI_CODE_EVENT_NAMES),
2811
2897
  "PermissionResult",
2812
- "Interrupt"
2898
+ "Interrupt",
2899
+ "TurnStarted",
2900
+ "UserPromptQueued",
2901
+ "TaskStarted",
2902
+ "SessionHeartbeat"
2813
2903
  ];
2814
2904
  const KIMI_CODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_KIMI_CODE_EVENT_NAMES).map(([canonical, kimiCode]) => [kimiCode, canonical]));
2815
2905
  /**
@@ -3086,14 +3176,20 @@ const CANONICAL_TO_OPENCODE_EVENT_NAMES = {
3086
3176
  stop: "session.idle",
3087
3177
  afterFileEdit: "file.edited",
3088
3178
  permissionRequest: "permission.asked",
3179
+ permissionDenied: "permission.replied",
3180
+ notification: "tui.toast.show",
3089
3181
  preCompact: "experimental.session.compacting",
3182
+ beforeSubmitPrompt: "chat.message",
3090
3183
  postCompact: "session.compacted",
3091
3184
  afterError: "session.error",
3092
3185
  fileChanged: "file.watcher.updated"
3093
3186
  };
3094
3187
  /**
3095
3188
  * Map canonical camelCase event names to Kilo dot-notation.
3096
- * (Currently identical to OpenCode)
3189
+ *
3190
+ * Shared with OpenCode: the two name the same events. The `notification` entry
3191
+ * is unreachable for Kilo because `KILO_HOOK_EVENTS` omits it, and the
3192
+ * generator emits only supported events.
3097
3193
  */
3098
3194
  const CANONICAL_TO_KILO_EVENT_NAMES = CANONICAL_TO_OPENCODE_EVENT_NAMES;
3099
3195
  /**
@@ -3131,6 +3227,16 @@ const CANONICAL_TO_AMP_EVENT_NAMES = {
3131
3227
  beforeSubmitPrompt: "agent.start",
3132
3228
  stop: "agent.end"
3133
3229
  };
3230
+ /** Map canonical hook events to Cline's `VALID_HOOK_TYPES` file names. */
3231
+ const CANONICAL_TO_CLINE_EVENT_NAMES = {
3232
+ sessionStart: "TaskStart",
3233
+ preToolUse: "PreToolUse",
3234
+ postToolUse: "PostToolUse",
3235
+ beforeSubmitPrompt: "UserPromptSubmit",
3236
+ preCompact: "PreCompact",
3237
+ notification: "Notification",
3238
+ taskCompleted: "TaskComplete"
3239
+ };
3134
3240
  /**
3135
3241
  * Map canonical camelCase event names to Copilot camelCase.
3136
3242
  */
@@ -3367,7 +3473,8 @@ const CANONICAL_TO_QWENCODE_EVENT_NAMES = {
3367
3473
  instructionsLoaded: "InstructionsLoaded",
3368
3474
  todoCreated: "TodoCreated",
3369
3475
  todoCompleted: "TodoCompleted",
3370
- messageDisplay: "MessageDisplay"
3476
+ messageDisplay: "MessageDisplay",
3477
+ sessionDelete: "SessionDelete"
3371
3478
  };
3372
3479
  /**
3373
3480
  * Map Qwen Code PascalCase event names to canonical camelCase.
@@ -4204,14 +4311,20 @@ const VibePermissionsOverrideSchema = zod_mini.z.looseObject({
4204
4311
  * Tool-scoped override block for Cursor CLI. Cursor's `cli.json` carries scalar
4205
4312
  * autonomy settings with no canonical permission category — `approvalMode`
4206
4313
  * (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object
4207
- * (`mode`/`networkAccess`). Fields placed here are merged into the top-level of
4208
- * `.cursor/cli.json` (project) / `~/.cursor/cli-config.json` (global) and emitted
4209
- * only for Cursor, while the shared `permission` block continues to drive the
4210
- * `permissions.allow`/`permissions.deny` arrays. Kept a `looseObject` so extra
4211
- * `cli.json` keys can be authored (they are merged verbatim on generate);
4212
- * `sandbox`'s accepted values are not documented so it passes through verbatim.
4314
+ * (`mode`/`networkAccess`). Fields placed here are merged into the top level of
4315
+ * `~/.cursor/cli-config.json` and emitted only for Cursor, while the shared
4316
+ * `permission` block continues to drive the `permissions.allow`/`permissions.deny`
4317
+ * arrays. Kept a `looseObject` so extra config keys can be authored (they are
4318
+ * merged verbatim on generate); `sandbox`'s accepted values are not documented
4319
+ * so it passes through verbatim.
4320
+ *
4321
+ * These are **global-only** settings: Cursor documents that "Only permissions
4322
+ * can be configured at the project level. All other CLI settings must be set
4323
+ * globally", so a project generate skips them with a warning instead of writing
4324
+ * keys `.cursor/cli.json` would ignore. Author them with `--global`.
4325
+ *
4213
4326
  * Note: only `approvalMode` and `sandbox` round-trip back on import — other keys
4214
- * authored here reach `cli.json` on generate but are not re-extracted.
4327
+ * authored here reach the global config on generate but are not re-extracted.
4215
4328
  *
4216
4329
  * @example
4217
4330
  * { "approvalMode": "auto-review" }
@@ -4229,7 +4342,10 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
4229
4342
  * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
4230
4343
  * autonomy/sandbox controls with no canonical permission category — under
4231
4344
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
4232
- * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`). It also
4345
+ * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`,
4346
+ * `allowedHttpHookUrls`, `allowPrivateNetworkHooks` — the latter is honored by
4347
+ * Qwen Code only in user/system settings, so generate skips it in project scope).
4348
+ * It also
4233
4349
  * exposes `permissions.autoMode` (the Auto Mode classifier config:
4234
4350
  * `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell` — see
4235
4351
  * https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/), which
@@ -4995,7 +5111,10 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
4995
5111
  name: zod_mini.z.optional(zod_mini.z.string()),
4996
5112
  description: zod_mini.z.optional(zod_mini.z.string())
4997
5113
  })),
4998
- pi: zod_mini.z.optional(zod_mini.z.looseObject({ systemPrompt: zod_mini.z.optional(zod_mini.z.enum(["append"])) })),
5114
+ pi: zod_mini.z.optional(zod_mini.z.looseObject({
5115
+ systemPrompt: zod_mini.z.optional(zod_mini.z.enum(["append"])),
5116
+ contextFile: zod_mini.z.optional(zod_mini.z.enum(["override"]))
5117
+ })),
4999
5118
  takt: zod_mini.z.optional(zod_mini.z.looseObject({
5000
5119
  name: zod_mini.z.optional(zod_mini.z.string()),
5001
5120
  extends: zod_mini.z.optional(zod_mini.z.string()),
@@ -5190,7 +5309,10 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
5190
5309
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
5191
5310
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
5192
5311
  "scheduled-task": zod_mini.z.optional(zod_mini.z.boolean()),
5193
- paths: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
5312
+ paths: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
5313
+ license: zod_mini.z.optional(zod_mini.z.string()),
5314
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
5315
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
5194
5316
  })),
5195
5317
  codexcli: zod_mini.z.optional(zod_mini.z.looseObject({
5196
5318
  "short-description": zod_mini.z.optional(zod_mini.z.string()),
@@ -5802,13 +5924,20 @@ var FeatureProcessor = class {
5802
5924
  filePath,
5803
5925
  content: contentWithNewline
5804
5926
  })) continue;
5927
+ const fileMode = aiFile.getFileMode?.();
5805
5928
  if (fileContentsEquivalent({
5806
5929
  filePath,
5807
5930
  expected: contentWithNewline,
5808
5931
  existing: existingContent
5809
- })) continue;
5932
+ })) {
5933
+ if (fileMode !== void 0 && !this.dryRun) await restoreMissingExecutableBit(filePath, fileMode);
5934
+ continue;
5935
+ }
5810
5936
  if (this.dryRun) this.logger.info(`[DRY RUN] Would write: ${filePath}`);
5811
- else await writeFileContent(filePath, contentWithNewline);
5937
+ else {
5938
+ await writeFileContent(filePath, contentWithNewline);
5939
+ if (fileMode !== void 0) await applyFileMode(filePath, fileMode);
5940
+ }
5812
5941
  changedCount++;
5813
5942
  changedPaths.push(aiFile.getRelativePathFromCwd());
5814
5943
  }
@@ -7169,10 +7298,16 @@ const SHARED_CONFIG_OWNERSHIP = {
7169
7298
  ".config/goose/config.yaml": {
7170
7299
  format: "yaml",
7171
7300
  invalidRootPolicy: "error",
7172
- features: { mcp: {
7173
- kind: "replace-owned-keys",
7174
- ownedKeys: ["extensions"]
7175
- } }
7301
+ features: {
7302
+ mcp: {
7303
+ kind: "replace-owned-keys",
7304
+ ownedKeys: ["extensions"]
7305
+ },
7306
+ commands: {
7307
+ kind: "replace-owned-keys",
7308
+ ownedKeys: ["slash_commands"]
7309
+ }
7310
+ }
7176
7311
  },
7177
7312
  [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
7178
7313
  format: "toml",
@@ -7876,6 +8011,22 @@ var ChecksProcessor = class extends FeatureProcessor {
7876
8011
  }
7877
8012
  };
7878
8013
  //#endregion
8014
+ //#region src/constants/goose-paths.ts
8015
+ const GOOSE_DIR = ".goose";
8016
+ const GOOSE_GLOBAL_DIR = (0, node_path.join)(".config", "goose");
8017
+ const GOOSE_RULE_FILE_NAME = ".goosehints";
8018
+ const GOOSE_MCP_FILE_NAME = "config.yaml";
8019
+ const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
8020
+ const GOOSE_HOOKS_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync", "hooks");
8021
+ const GOOSE_HOOKS_FILE_NAME = "hooks.json";
8022
+ const GOOSE_PLUGIN_MCP_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync");
8023
+ const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
8024
+ const GOOSE_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "skills");
8025
+ const GOOSE_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "recipes");
8026
+ const GOOSE_GLOBAL_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "recipes");
8027
+ const GOOSE_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "agents");
8028
+ const GOOSE_GLOBAL_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "agents");
8029
+ //#endregion
7879
8030
  //#region src/utils/tool-home.ts
7880
8031
  /**
7881
8032
  * Where the rulesync-side source files of a tool with a home override belong.
@@ -8817,6 +8968,9 @@ const CLINE_MCP_DIR_PATH = (0, node_path.join)(CLINE_DIR, "data", "settings");
8817
8968
  const CLINE_MCP_FILE_NAME = "cline_mcp_settings.json";
8818
8969
  const CLINE_PERMISSIONS_FILE_NAME = "command-permissions.json";
8819
8970
  const CLINE_IGNORE_FILE_NAME = ".clineignore";
8971
+ const CLINE_HOOKS_DIR_PATH = (0, node_path.join)(CLINERULES_DIR, "hooks");
8972
+ const CLINE_HOOKS_GLOBAL_DIR_PATH = (0, node_path.join)("Documents", "Cline", "Hooks");
8973
+ const CLINE_HOOKS_MANIFEST_FILE_NAME = "rulesync-hooks.json";
8820
8974
  //#endregion
8821
8975
  //#region src/features/commands/cline-command.ts
8822
8976
  var ClineCommand = class ClineCommand extends ToolCommand {
@@ -9278,6 +9432,8 @@ const DEVIN_HOOKS_V1_FILE_NAME = "hooks.v1.json";
9278
9432
  const DEVIN_GLOBAL_AGENTS_FILE_NAME = "AGENTS.md";
9279
9433
  const DEVIN_IGNORE_FILE_NAME = ".devinignore";
9280
9434
  const DEVIN_LEGACY_IGNORE_FILE_NAME = ".codeiumignore";
9435
+ const DEVIN_GLOBAL_IGNORE_DIR_PATH = ".codeium";
9436
+ const DEVIN_GLOBAL_IGNORE_FILE_NAME = DEVIN_LEGACY_IGNORE_FILE_NAME;
9281
9437
  //#endregion
9282
9438
  //#region src/features/commands/command-skill-ownership.ts
9283
9439
  /**
@@ -9526,24 +9682,99 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9526
9682
  }
9527
9683
  };
9528
9684
  //#endregion
9529
- //#region src/constants/goose-paths.ts
9530
- const GOOSE_DIR = ".goose";
9531
- const GOOSE_GLOBAL_DIR = (0, node_path.join)(".config", "goose");
9532
- const GOOSE_RULE_FILE_NAME = ".goosehints";
9533
- const GOOSE_MCP_FILE_NAME = "config.yaml";
9534
- const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
9535
- const GOOSE_HOOKS_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync", "hooks");
9536
- const GOOSE_HOOKS_FILE_NAME = "hooks.json";
9537
- const GOOSE_PLUGIN_MCP_DIR_PATH = (0, node_path.join)(".agents", "plugins", "rulesync");
9538
- const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
9539
- const GOOSE_SKILLS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "skills");
9540
- const GOOSE_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "recipes");
9541
- const GOOSE_GLOBAL_RECIPES_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "recipes");
9542
- const GOOSE_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_DIR, "agents");
9543
- const GOOSE_GLOBAL_AGENTS_DIR_PATH = (0, node_path.join)(GOOSE_GLOBAL_DIR, "agents");
9544
- //#endregion
9545
9685
  //#region src/features/commands/goose-command.ts
9546
9686
  const RECIPE_VERSION = "1.0.0";
9687
+ const SLASH_COMMANDS_KEY = "slash_commands";
9688
+ const GOOSE_GLOBAL_RECIPES_POSIX_DIR = toPosixPath(GOOSE_GLOBAL_RECIPES_DIR_PATH);
9689
+ function slashCommandEntry({ outputRoot, relativeFilePath }) {
9690
+ const fileName = (0, node_path.basename)(toPosixPath(relativeFilePath));
9691
+ return {
9692
+ command: fileName.replace(/\.ya?ml$/, "").toLowerCase(),
9693
+ recipe_path: (0, node_path.join)(outputRoot, GOOSE_GLOBAL_RECIPES_DIR_PATH, fileName)
9694
+ };
9695
+ }
9696
+ /**
9697
+ * Whether an existing `slash_commands` entry points at a recipe rulesync owns:
9698
+ * a direct child of the global recipes directory. Anything else — a recipe
9699
+ * elsewhere on disk, or a sub-recipe under `recipes/subagents/` — belongs to
9700
+ * the user and is carried over untouched. A path that cannot be resolved into
9701
+ * the managed directory is preserved rather than claimed.
9702
+ */
9703
+ function isManagedRecipePath(value) {
9704
+ if (typeof value !== "string") return false;
9705
+ const segments = toPosixPath(value).replace(/^~\//, "").split("/");
9706
+ const fileName = segments.pop();
9707
+ if (fileName === void 0 || fileName === "") return false;
9708
+ const dirSegments = GOOSE_GLOBAL_RECIPES_POSIX_DIR.split("/");
9709
+ return segments.length >= dirSegments.length && segments.slice(-dirSegments.length).join("/") === GOOSE_GLOBAL_RECIPES_POSIX_DIR;
9710
+ }
9711
+ /**
9712
+ * Whether a config file carries a registration rulesync owns. Rewriting a
9713
+ * config that holds none of them would reformat the user's file (comments and
9714
+ * all) for nothing, so both writers check this first.
9715
+ */
9716
+ function hasManagedGooseSlashCommands(fileContent) {
9717
+ const existing = parseSharedConfig({
9718
+ format: "yaml",
9719
+ fileContent
9720
+ })[SLASH_COMMANDS_KEY];
9721
+ return Array.isArray(existing) && existing.some((entry) => isRecord$1(entry) && isManagedRecipePath(entry.recipe_path));
9722
+ }
9723
+ /**
9724
+ * Recompute `slash_commands` from the entries rulesync generates: user entries
9725
+ * pointing outside the managed recipes directory are carried over, entries
9726
+ * inside it are replaced (so a deleted command's registration is retracted),
9727
+ * and the key is dropped entirely when nothing is left.
9728
+ */
9729
+ function getGooseSlashCommandsConfigContent({ currentContent, entries }) {
9730
+ const config = parseSharedConfig({
9731
+ format: "yaml",
9732
+ fileContent: currentContent
9733
+ });
9734
+ const next = [...(Array.isArray(config[SLASH_COMMANDS_KEY]) ? config[SLASH_COMMANDS_KEY] : []).filter((entry) => !isRecord$1(entry) || !isManagedRecipePath(entry.recipe_path)), ...entries];
9735
+ return applySharedConfigPatch({
9736
+ fileKey: sharedConfigFileKey({
9737
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9738
+ relativeFilePath: GOOSE_MCP_FILE_NAME
9739
+ }),
9740
+ feature: "commands",
9741
+ existingContent: currentContent,
9742
+ patch: { [SLASH_COMMANDS_KEY]: next.length > 0 ? next : void 0 }
9743
+ });
9744
+ }
9745
+ /**
9746
+ * The Goose user `config.yaml`, carrying the `slash_commands` registrations for
9747
+ * the generated recipes. The file is shared with the user's own settings, so it
9748
+ * is always merged into rather than replaced.
9749
+ */
9750
+ var GooseCommandConfigFile = class extends ToolFile {
9751
+ entries;
9752
+ constructor(params) {
9753
+ super(params);
9754
+ this.entries = params.entries;
9755
+ }
9756
+ validate() {
9757
+ return {
9758
+ success: true,
9759
+ error: null
9760
+ };
9761
+ }
9762
+ shouldMergeExistingFileContent() {
9763
+ return true;
9764
+ }
9765
+ setFileContent(newFileContent) {
9766
+ super.setFileContent(getGooseSlashCommandsConfigContent({
9767
+ currentContent: newFileContent,
9768
+ entries: this.entries
9769
+ }));
9770
+ }
9771
+ getFileContent() {
9772
+ return getGooseSlashCommandsConfigContent({
9773
+ currentContent: super.getFileContent(),
9774
+ entries: this.entries
9775
+ });
9776
+ }
9777
+ };
9547
9778
  /**
9548
9779
  * Goose recipe files are reusable YAML workflow documents. A recipe requires
9549
9780
  * `version`, `title`, and `description`, plus at least one of `instructions` /
@@ -9578,6 +9809,40 @@ var GooseCommand = class GooseCommand extends ToolCommand {
9578
9809
  static getSettablePaths({ global = false } = {}) {
9579
9810
  return { relativeDirPath: global ? GOOSE_GLOBAL_RECIPES_DIR_PATH : GOOSE_RECIPES_DIR_PATH };
9580
9811
  }
9812
+ /**
9813
+ * The user `config.yaml` holding the `slash_commands` registrations. Global
9814
+ * scope only — Goose has no project-level registration surface.
9815
+ */
9816
+ static getExtraSharedWritePaths({ global = false } = {}) {
9817
+ if (!global) return [];
9818
+ return [{
9819
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9820
+ relativeFilePath: GOOSE_MCP_FILE_NAME
9821
+ }];
9822
+ }
9823
+ /**
9824
+ * Register the generated recipes as slash commands. The config file is also
9825
+ * emitted when no command is generated but the existing file still carries
9826
+ * managed registrations, so removing the last command retracts them instead of
9827
+ * leaving `/name` pointing at a deleted recipe.
9828
+ */
9829
+ static async getAuxiliaryFiles({ toolCommands, outputRoot = process.cwd(), global = false, forDeletion = false }) {
9830
+ if (!global || forDeletion) return [];
9831
+ const entries = toolCommands.map((command) => slashCommandEntry({
9832
+ outputRoot,
9833
+ relativeFilePath: command.getRelativeFilePath()
9834
+ }));
9835
+ const existingContent = await readFileContentOrNull((0, node_path.join)(outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME));
9836
+ if (entries.length === 0 && !hasManagedGooseSlashCommands(existingContent ?? "")) return [];
9837
+ return [new GooseCommandConfigFile({
9838
+ outputRoot,
9839
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9840
+ relativeFilePath: GOOSE_MCP_FILE_NAME,
9841
+ fileContent: existingContent ?? "",
9842
+ entries,
9843
+ global
9844
+ })];
9845
+ }
9581
9846
  parseRecipeContent(content) {
9582
9847
  const where = (0, node_path.join)(this.relativeDirPath, this.relativeFilePath);
9583
9848
  let parsed;
@@ -11063,8 +11328,9 @@ const KIRO_SETTINGS_DIR_PATH = (0, node_path.join)(KIRO_DIR, "settings");
11063
11328
  const KIRO_AGENTS_DIR_PATH = (0, node_path.join)(KIRO_DIR, "agents");
11064
11329
  const KIRO_HOOKS_FILE_NAME = "default.json";
11065
11330
  /**
11066
- * Kiro IDE 1.0 stores hooks as structured JSON files in `.kiro/hooks/`
11067
- * (workspace) and `~/.kiro/hooks/` (user). A single file may declare multiple
11331
+ * Kiro stores hooks as structured JSON files in `.kiro/hooks/` (workspace) and
11332
+ * `~/.kiro/hooks/` (user) the format the IDE reads and the one Kiro CLI 3.0
11333
+ * migrated to. A single file may declare multiple
11068
11334
  * hooks in its `hooks` array, so rulesync emits all generated hooks into one
11069
11335
  * `rulesync.json` file per scope.
11070
11336
  * @see https://kiro.dev/docs/hooks/
@@ -11407,6 +11673,7 @@ const PI_EXTENSIONS_DIR_PATH = (0, node_path.join)(".pi", "extensions");
11407
11673
  const PI_PROMPTS_DIR_PATH = (0, node_path.join)(".pi", "prompts");
11408
11674
  const PI_SKILLS_DIR_PATH = (0, node_path.join)(".pi", "skills");
11409
11675
  const PI_RULE_FILE_NAME = "AGENTS.md";
11676
+ const PI_RULE_OVERRIDE_FILE_NAME = "AGENTS.override.md";
11410
11677
  const PI_APPEND_SYSTEM_FILE_NAME = "APPEND_SYSTEM.md";
11411
11678
  const PI_HOOKS_FILE_NAME = "rulesync-hooks.ts";
11412
11679
  //#endregion
@@ -12898,6 +13165,7 @@ var CommandsProcessor = class extends FeatureProcessor {
12898
13165
  }));
12899
13166
  const shouldDisableHermesCommandsPlugin = this.toolTarget === "hermesagent" && existingFiles.some((file) => file.getFilePath() === ownershipPath) && !generatedFiles.some((file) => file.getFilePath() === ownershipPath);
12900
13167
  let changedCount = await super.removeOrphanAiFiles(existingFiles, generatedFiles);
13168
+ changedCount += await this.retractGooseSlashCommands(generatedFiles);
12901
13169
  if (!shouldDisableHermesCommandsPlugin) return changedCount;
12902
13170
  const configPath = (0, node_path.join)(this.outputRoot, getHermesagentRelativeFilePath({
12903
13171
  global: this.global,
@@ -12916,6 +13184,27 @@ var CommandsProcessor = class extends FeatureProcessor {
12916
13184
  return changedCount;
12917
13185
  }
12918
13186
  /**
13187
+ * Drop the `slash_commands` registrations when no Goose recipe is generated
13188
+ * any more. `GooseCommand.getAuxiliaryFiles` handles every other case, but it
13189
+ * is not reached when the whole feature has no source files left (`--delete`
13190
+ * removes the recipes there), which would strand `/name` on a deleted recipe.
13191
+ */
13192
+ async retractGooseSlashCommands(generatedFiles) {
13193
+ if (this.toolTarget !== "goose" || !this.global) return 0;
13194
+ if (generatedFiles.some((file) => file instanceof GooseCommand)) return 0;
13195
+ const configPath = (0, node_path.join)(this.outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME);
13196
+ const currentContent = await readFileContentOrNull(configPath);
13197
+ if (currentContent === null || !hasManagedGooseSlashCommands(currentContent)) return 0;
13198
+ const nextContent = getGooseSlashCommandsConfigContent({
13199
+ currentContent,
13200
+ entries: []
13201
+ });
13202
+ if (nextContent === currentContent) return 0;
13203
+ if (this.dryRun) this.logger.info(`[DRY RUN] Would write: ${configPath}`);
13204
+ else await writeFileContent(configPath, nextContent);
13205
+ return 1;
13206
+ }
13207
+ /**
12919
13208
  * Implementation of abstract method from FeatureProcessor
12920
13209
  * Return the tool targets that this processor supports
12921
13210
  */
@@ -13084,6 +13373,14 @@ var ToolHooks = class extends ToolFile {
13084
13373
  static async getAuxiliaryFiles(_params) {
13085
13374
  return [];
13086
13375
  }
13376
+ /**
13377
+ * Extra files the deletion sweep may remove, for adapters that write more
13378
+ * than their settable path. Kept separate from {@link getAuxiliaryFiles},
13379
+ * which may legitimately return a shared user-owned config file.
13380
+ */
13381
+ static async getDeletableAuxiliaryFiles(_params) {
13382
+ return [];
13383
+ }
13087
13384
  };
13088
13385
  //#endregion
13089
13386
  //#region src/features/hooks/amp-hooks.ts
@@ -14366,8 +14663,7 @@ const CLAUDE_CONVERTER_CONFIG = {
14366
14663
  "teammateIdle",
14367
14664
  "cwdChanged",
14368
14665
  "beforeSubmitPrompt",
14369
- "stop",
14370
- "directoryAdded"
14666
+ "stop"
14371
14667
  ]),
14372
14668
  supportedHookTypes: /* @__PURE__ */ new Set([
14373
14669
  "command",
@@ -14541,6 +14837,281 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
14541
14837
  }
14542
14838
  };
14543
14839
  //#endregion
14840
+ //#region src/features/hooks/cline-hooks-generator.ts
14841
+ /**
14842
+ * Marker line every generated hook script carries. Cline resolves hooks by
14843
+ * exact event name from a directory users also hand-author scripts in, so the
14844
+ * marker is what tells a rulesync-owned script apart from a user's own: only
14845
+ * files carrying it are rewritten or cleaned up.
14846
+ */
14847
+ const CLINE_HOOK_SCRIPT_MARKER = "rulesync-owned: cline-hooks";
14848
+ /** Exit code a hook command uses to cancel the task (Claude Code convention). */
14849
+ const CANCEL_EXIT_CODE = 2;
14850
+ function sanitizeCommand(command) {
14851
+ let sanitized = command;
14852
+ for (const char of CONTROL_CHARS) sanitized = sanitized.replaceAll(char, "");
14853
+ return sanitized;
14854
+ }
14855
+ /** Single-quote a string for POSIX shells. */
14856
+ function shellQuote(value) {
14857
+ return `'${value.replaceAll("'", `'\\''`)}'`;
14858
+ }
14859
+ /** Single-quote a string for PowerShell. */
14860
+ function powerShellQuote(value) {
14861
+ return `'${value.replaceAll("'", "''")}'`;
14862
+ }
14863
+ function collectClineHookCommands({ effectiveHooks, eventMap }) {
14864
+ const commandsByEvent = {};
14865
+ for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
14866
+ const clineEvent = eventMap[canonicalEvent];
14867
+ if (!clineEvent) continue;
14868
+ const commands = definitions.filter((definition) => (definition.type ?? "command") === "command" && definition.command).map((definition) => sanitizeCommand(definition.command)).filter((command) => command.trim() !== "");
14869
+ if (commands.length === 0) continue;
14870
+ const existing = commandsByEvent[clineEvent];
14871
+ if (existing) existing.push(...commands);
14872
+ else commandsByEvent[clineEvent] = commands;
14873
+ }
14874
+ return commandsByEvent;
14875
+ }
14876
+ /**
14877
+ * A POSIX wrapper script for one Cline hook event.
14878
+ *
14879
+ * Cline spawns the file itself (`spawn(scriptPath, [], { shell: true })`), feeds
14880
+ * the event payload on stdin and reads a JSON result from stdout. The wrapper
14881
+ * therefore forwards the payload to each configured command in order and
14882
+ * translates the exit codes: `2` cancels the task, any other failure is
14883
+ * reported through `errorMessage` without cancelling, mirroring how the
14884
+ * canonical `command` hook type behaves for the tools that support blocking.
14885
+ */
14886
+ function generateClineHookScript({ event, commands }) {
14887
+ const lines = [
14888
+ "#!/bin/bash",
14889
+ `# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
14890
+ `# ${CLINE_HOOK_SCRIPT_MARKER}`,
14891
+ "",
14892
+ "payload=$(cat)",
14893
+ "cancel=false",
14894
+ "error_message=''",
14895
+ ""
14896
+ ];
14897
+ for (const command of commands) {
14898
+ const quoted = shellQuote(command);
14899
+ lines.push("if [ \"$cancel\" = false ]; then", ` if bash -n -c ${quoted} 2>/dev/null; then`, ` hook_stderr=$(printf '%s' "$payload" | bash -c ${quoted} 2>&1 >/dev/null)`, " hook_status=$?", ` if [ "$hook_status" -eq ${CANCEL_EXIT_CODE} ]; then`, " cancel=true", " error_message=\"$hook_stderr\"", " elif [ \"$hook_status\" -ne 0 ]; then", ` printf '%s\\n' "rulesync ${event} hook failed (exit $hook_status): $hook_stderr" >&2`, " error_message=\"$hook_stderr\"", " fi", " else", ` error_message="rulesync ${event} hook command is not valid shell syntax"`, ` printf '%s\\n' "$error_message" >&2`, " fi", "fi", "");
14900
+ }
14901
+ lines.push("escape_json() {", ` printf '%s' "$1" | tr '\\n\\r\\t' ' ' | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g'`, "}", "", `printf '{"cancel": %s, "contextModification": "", "errorMessage": "%s"}\\n' "$cancel" "$(escape_json "$error_message")"`, "");
14902
+ return lines.join("\n");
14903
+ }
14904
+ /**
14905
+ * The PowerShell twin of {@link generateClineHookScript}. On Windows Cline
14906
+ * resolves only `<Event>.ps1` and runs it through `powershell -File`, so both
14907
+ * spellings are written and the platform picks one.
14908
+ */
14909
+ function generateClineHookPowerShellScript({ event, commands }) {
14910
+ const lines = [
14911
+ `# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
14912
+ `# ${CLINE_HOOK_SCRIPT_MARKER}`,
14913
+ "",
14914
+ "$payload = [Console]::In.ReadToEnd()",
14915
+ "$cancel = $false",
14916
+ "$errorMessage = ''",
14917
+ ""
14918
+ ];
14919
+ for (const command of commands) lines.push("if (-not $cancel) {", ` $hookStderr = ($payload | & cmd /c ${powerShellQuote(command)} 2>&1 | Out-String)`, " $hookStatus = $LASTEXITCODE", ` if ($hookStatus -eq ${CANCEL_EXIT_CODE}) {`, " $cancel = $true", " $errorMessage = $hookStderr", " } elseif ($hookStatus -ne 0) {", ` Write-Error ${powerShellQuote(`rulesync ${event} hook failed`)}`, " $errorMessage = $hookStderr", " }", "}", "");
14920
+ lines.push("@{", " cancel = $cancel", " contextModification = \"\"", " errorMessage = $errorMessage", "} | ConvertTo-Json -Compress", "");
14921
+ return lines.join("\n");
14922
+ }
14923
+ //#endregion
14924
+ //#region src/features/hooks/cline-hooks.ts
14925
+ /** Mode Cline's hook scripts need: it spawns the file itself on Unix. */
14926
+ const HOOK_SCRIPT_MODE = 493;
14927
+ /** The only file names this adapter ever writes into the hooks directory. */
14928
+ const MANAGED_EVENT_NAMES = new Set(Object.values(CANONICAL_TO_CLINE_EVENT_NAMES));
14929
+ /**
14930
+ * Read the manifest of a previous run. Event names are filtered against the
14931
+ * names this adapter emits: the manifest is a file in the repository, and
14932
+ * anything else there would otherwise be turned into a path — an executable one
14933
+ * — that rulesync writes.
14934
+ */
14935
+ function parseManifest(fileContent) {
14936
+ try {
14937
+ const parsed = JSON.parse(fileContent);
14938
+ if (!isRecord$1(parsed) || !isStringArray$1(parsed.events)) return null;
14939
+ return {
14940
+ generatedBy: "rulesync",
14941
+ events: parsed.events.filter((event) => MANAGED_EVENT_NAMES.has(event))
14942
+ };
14943
+ } catch {
14944
+ return null;
14945
+ }
14946
+ }
14947
+ /** One generated hook script. Written executable so Cline can spawn it. */
14948
+ var ClineHookScript = class extends ToolFile {
14949
+ getFileMode() {
14950
+ return this.getRelativeFilePath().endsWith(".ps1") ? void 0 : HOOK_SCRIPT_MODE;
14951
+ }
14952
+ validate() {
14953
+ return {
14954
+ success: true,
14955
+ error: null
14956
+ };
14957
+ }
14958
+ };
14959
+ /**
14960
+ * Hooks adapter for Cline's file-based hooks.
14961
+ *
14962
+ * Cline (VS Code extension / Cline Desktop) resolves one executable per
14963
+ * lifecycle event from `<project>/.clinerules/hooks/` or the global
14964
+ * `~/Documents/Cline/Hooks/`, named exactly after the event: the extensionless
14965
+ * name on Unix, `<Event>.ps1` on Windows. The script receives the event payload
14966
+ * as JSON on stdin and answers with `{"cancel": …, "contextModification": …,
14967
+ * "errorMessage": …}` on stdout. rulesync emits a wrapper script per configured
14968
+ * event in both spellings, plus a `rulesync-hooks.json` manifest naming the
14969
+ * scripts it owns.
14970
+ *
14971
+ * The directory is shared with hand-authored hooks and the filenames are fixed
14972
+ * by the contract, so every generated script carries a marker line and a script
14973
+ * without it is never overwritten.
14974
+ *
14975
+ * Cline's CLI and SDK use a different, in-process hook surface (`AgentHooks`
14976
+ * from `@cline/core`), which this adapter does not target.
14977
+ *
14978
+ * @see https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts
14979
+ */
14980
+ var ClineHooks = class ClineHooks extends ToolHooks {
14981
+ scriptsByEvent;
14982
+ constructor(params) {
14983
+ super({
14984
+ ...params,
14985
+ fileContent: params.fileContent ?? ""
14986
+ });
14987
+ this.scriptsByEvent = params.scriptsByEvent ?? {};
14988
+ }
14989
+ static getSettablePaths(options) {
14990
+ return {
14991
+ relativeDirPath: options?.global ? CLINE_HOOKS_GLOBAL_DIR_PATH : CLINE_HOOKS_DIR_PATH,
14992
+ relativeFilePath: CLINE_HOOKS_MANIFEST_FILE_NAME
14993
+ };
14994
+ }
14995
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
14996
+ const paths = ClineHooks.getSettablePaths({ global });
14997
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath));
14998
+ return new ClineHooks({
14999
+ outputRoot,
15000
+ ...paths,
15001
+ fileContent,
15002
+ validate
15003
+ });
15004
+ }
15005
+ static fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
15006
+ const config = rulesyncHooks.getJson();
15007
+ const overrideHooks = config.cline?.hooks ?? {};
15008
+ const scriptsByEvent = collectClineHookCommands({
15009
+ effectiveHooks: {
15010
+ ...config.hooks,
15011
+ ...overrideHooks
15012
+ },
15013
+ eventMap: CANONICAL_TO_CLINE_EVENT_NAMES
15014
+ });
15015
+ const manifest = {
15016
+ generatedBy: "rulesync",
15017
+ events: Object.keys(scriptsByEvent).toSorted()
15018
+ };
15019
+ return new ClineHooks({
15020
+ outputRoot,
15021
+ ...ClineHooks.getSettablePaths({ global }),
15022
+ fileContent: `${JSON.stringify(manifest, null, 2)}\n`,
15023
+ validate,
15024
+ scriptsByEvent
15025
+ });
15026
+ }
15027
+ /**
15028
+ * The per-event scripts, plus a neutralized script for every event a previous
15029
+ * run generated and this one no longer covers — those files stay on disk (the
15030
+ * hooks feature only reconciles its single settable path), so they are
15031
+ * rewritten as no-ops instead of being left running a removed hook.
15032
+ */
15033
+ async getScriptFiles({ global = false, logger } = {}) {
15034
+ const paths = ClineHooks.getSettablePaths({ global });
15035
+ const previous = parseManifest(await readFileContentOrNull((0, node_path.join)(this.outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "")?.events ?? [];
15036
+ const events = [.../* @__PURE__ */ new Set([...Object.keys(this.scriptsByEvent), ...previous])].toSorted();
15037
+ const files = [];
15038
+ for (const event of events) {
15039
+ const commands = this.scriptsByEvent[event] ?? [];
15040
+ for (const [relativeFilePath, fileContent] of [[event, generateClineHookScript({
15041
+ event,
15042
+ commands
15043
+ })], [`${event}.ps1`, generateClineHookPowerShellScript({
15044
+ event,
15045
+ commands
15046
+ })]]) {
15047
+ const existing = await readFileContentOrNull((0, node_path.join)(this.outputRoot, paths.relativeDirPath, relativeFilePath));
15048
+ if (existing !== null && !existing.includes("rulesync-owned: cline-hooks")) {
15049
+ logger?.warn(`Kept the existing ${(0, node_path.join)(paths.relativeDirPath, relativeFilePath)}: it was not generated by rulesync, so the ${event} hook from .rulesync/hooks.jsonc is not written. Remove or rename that file to let rulesync manage the event.`);
15050
+ continue;
15051
+ }
15052
+ files.push(new ClineHookScript({
15053
+ outputRoot: this.outputRoot,
15054
+ relativeDirPath: paths.relativeDirPath,
15055
+ relativeFilePath,
15056
+ fileContent
15057
+ }));
15058
+ }
15059
+ }
15060
+ return files;
15061
+ }
15062
+ /**
15063
+ * The generated scripts ride alongside the manifest. Only a `ClineHooks`
15064
+ * instance can produce them, so the processor hands its freshly built one
15065
+ * back here.
15066
+ */
15067
+ static async getAuxiliaryFiles({ global = false, toolHooks, logger } = {}) {
15068
+ if (!(toolHooks instanceof ClineHooks)) return [];
15069
+ return toolHooks.getScriptFiles({
15070
+ global,
15071
+ logger
15072
+ });
15073
+ }
15074
+ /**
15075
+ * Every rulesync-marked script currently on disk. Dropping the target must
15076
+ * take the scripts with it: they hold the actual commands, and leaving them
15077
+ * behind keeps a removed hook running.
15078
+ */
15079
+ static async getDeletableAuxiliaryFiles({ outputRoot = process.cwd(), global = false } = {}) {
15080
+ const paths = ClineHooks.getSettablePaths({ global });
15081
+ const files = [];
15082
+ for (const event of [...MANAGED_EVENT_NAMES].toSorted()) for (const relativeFilePath of [event, `${event}.ps1`]) {
15083
+ const existing = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath));
15084
+ if (existing === null || !existing.includes("rulesync-owned: cline-hooks")) continue;
15085
+ files.push(new ClineHookScript({
15086
+ outputRoot,
15087
+ relativeDirPath: paths.relativeDirPath,
15088
+ relativeFilePath,
15089
+ fileContent: "",
15090
+ validate: false
15091
+ }));
15092
+ }
15093
+ return files;
15094
+ }
15095
+ toRulesyncHooks() {
15096
+ throw new Error("Not implemented because generated Cline hook scripts cannot be imported back into canonical hooks.");
15097
+ }
15098
+ validate() {
15099
+ return {
15100
+ success: true,
15101
+ error: null
15102
+ };
15103
+ }
15104
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
15105
+ return new ClineHooks({
15106
+ outputRoot,
15107
+ relativeDirPath,
15108
+ relativeFilePath,
15109
+ fileContent: "",
15110
+ validate: false
15111
+ });
15112
+ }
15113
+ };
15114
+ //#endregion
14544
15115
  //#region src/features/hooks/codexcli-hooks.ts
14545
15116
  const CODEXCLI_CONVERTER_CONFIG = {
14546
15117
  supportedEvents: CODEXCLI_HOOK_EVENTS,
@@ -14707,12 +15278,19 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14707
15278
  /**
14708
15279
  * Copilot hook entry as stored in .github/hooks/copilot-hooks.json.
14709
15280
  *
14710
- * On Windows, commands are emitted under `powershell`; on other platforms, under `bash`.
15281
+ * The canonical `shell` selector chooses `bash` or `powershell`; without it the
15282
+ * portable `command` field is written, which upstream copies to both when
15283
+ * neither is present. Note the cloud agent runs hooks in a Linux sandbox and
15284
+ * honors only `bash` and `command` — a `powershell` entry is ignored there.
15285
+ *
15286
+ * @see https://docs.github.com/en/copilot/reference/hooks-reference
14711
15287
  */
14712
15288
  const CopilotHookEntrySchema = zod_mini.z.looseObject({
14713
15289
  type: zod_mini.z.string(),
14714
15290
  bash: zod_mini.z.optional(zod_mini.z.string()),
14715
15291
  powershell: zod_mini.z.optional(zod_mini.z.string()),
15292
+ command: zod_mini.z.optional(zod_mini.z.string()),
15293
+ env: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
14716
15294
  timeoutSec: zod_mini.z.optional(zod_mini.z.number())
14717
15295
  });
14718
15296
  /**
@@ -14720,12 +15298,16 @@ const CopilotHookEntrySchema = zod_mini.z.looseObject({
14720
15298
  * Filters shared hooks to COPILOT_HOOK_EVENTS, merges config.copilot?.hooks,
14721
15299
  * then converts to Copilot event names and field format.
14722
15300
  *
14723
- * On Windows the command is emitted under the `powershell` key;
14724
- * on all other platforms it is emitted under `bash`.
15301
+ * The command field is chosen by the canonical `shell` selector, falling back to
15302
+ * the portable `command` field never by the platform Rulesync happens to run
15303
+ * on. Keying it off `process.platform` meant a file generated on Windows carried
15304
+ * only `powershell`, which the Linux-sandboxed cloud agent ignores outright, so
15305
+ * the hook silently never ran; it also made the output differ per generating
15306
+ * machine, which shows up as churn for anyone who checks the file in (the cloud
15307
+ * agent reads it from the repository).
14725
15308
  */
14726
15309
  function canonicalToCopilotHooks(config) {
14727
15310
  const canonicalSchemaKeys = Object.keys(HookDefinitionSchema.shape);
14728
- const commandField = process.platform === "win32" ? "powershell" : "bash";
14729
15311
  const supported = new Set(COPILOT_HOOK_EVENTS);
14730
15312
  const sharedConfigHooks = {};
14731
15313
  for (const [event, defs] of Object.entries(config.hooks)) if (supported.has(event)) sharedConfigHooks[event] = defs;
@@ -14743,10 +15325,12 @@ function canonicalToCopilotHooks(config) {
14743
15325
  if (hookType !== "command") continue;
14744
15326
  const command = def.command;
14745
15327
  const timeout = def.timeout;
15328
+ const commandField = def.shell ?? "command";
14746
15329
  const rest = Object.fromEntries(Object.entries(def).filter(([k]) => !canonicalSchemaKeys.includes(k)));
14747
15330
  entries.push({
14748
15331
  type: hookType,
14749
15332
  ...command !== void 0 && command !== null && { [commandField]: command },
15333
+ ...def.env !== void 0 && { env: def.env },
14750
15334
  ...timeout !== void 0 && timeout !== null && { timeoutSec: timeout },
14751
15335
  ...rest
14752
15336
  });
@@ -14756,24 +15340,35 @@ function canonicalToCopilotHooks(config) {
14756
15340
  return copilot;
14757
15341
  }
14758
15342
  /**
14759
- * Resolve the command string from a Copilot hook entry.
15343
+ * Resolve the command and its shell selector from a Copilot hook entry.
14760
15344
  *
14761
- * - If only `bash` is present, use it.
14762
- * - If only `powershell` is present, use it.
14763
- * - If both are present, use `powershell` on Windows, `bash` otherwise,
14764
- * and log a warning that the other value was ignored.
15345
+ * - If only one shell-specific field is present, use it and record the shell,
15346
+ * so a re-export writes the same field back.
15347
+ * - If both are present, take `bash` and warn. The choice is deliberately not
15348
+ * platform-dependent: the cloud agent runs hooks in a Linux sandbox and
15349
+ * ignores `powershell` entirely, and importing on Windows must not produce a
15350
+ * different canonical config than importing the same file on Linux.
15351
+ * - Otherwise fall back to the portable `command` field, leaving `shell` unset
15352
+ * so a re-export renders the portable field again.
14765
15353
  */
14766
15354
  function resolveImportCommand$1(entry, logger) {
14767
15355
  const hasBash = typeof entry.bash === "string";
14768
15356
  const hasPowershell = typeof entry.powershell === "string";
14769
15357
  if (hasBash && hasPowershell) {
14770
- const isWindows = process.platform === "win32";
14771
- const chosen = isWindows ? "powershell" : "bash";
14772
- const ignored = isWindows ? "bash" : "powershell";
14773
- logger?.warn(`Copilot hook has both bash and powershell commands; using ${chosen} and ignoring ${ignored} on this platform.`);
14774
- return isWindows ? entry.powershell : entry.bash;
14775
- } else if (hasBash) return entry.bash;
14776
- else if (hasPowershell) return entry.powershell;
15358
+ logger?.warn("Copilot hook has both bash and powershell commands; using bash and ignoring powershell, which the Linux-sandboxed cloud agent does not run.");
15359
+ return {
15360
+ command: entry.bash,
15361
+ shell: "bash"
15362
+ };
15363
+ } else if (hasBash) return {
15364
+ command: entry.bash,
15365
+ shell: "bash"
15366
+ };
15367
+ else if (hasPowershell) return {
15368
+ command: entry.powershell,
15369
+ shell: "powershell"
15370
+ };
15371
+ return typeof entry.command === "string" ? { command: entry.command } : {};
14777
15372
  }
14778
15373
  /**
14779
15374
  * Extract hooks from Copilot hooks JSON into canonical format.
@@ -14790,11 +15385,13 @@ function copilotHooksToCanonical(copilotHooks, logger) {
14790
15385
  const parseResult = CopilotHookEntrySchema.safeParse(rawEntry);
14791
15386
  if (!parseResult.success) continue;
14792
15387
  const entry = parseResult.data;
14793
- const command = resolveImportCommand$1(entry, logger);
15388
+ const { command, shell } = resolveImportCommand$1(entry, logger);
14794
15389
  const timeout = entry.timeoutSec;
14795
15390
  defs.push({
14796
15391
  type: "command",
14797
15392
  ...command !== void 0 && { command },
15393
+ ...shell !== void 0 && { shell },
15394
+ ...entry.env !== void 0 && { env: entry.env },
14798
15395
  ...timeout !== void 0 && { timeout }
14799
15396
  });
14800
15397
  }
@@ -15953,6 +16550,27 @@ const HERMESAGENT_MATCHER_EVENTS = /* @__PURE__ */ new Set(["pre_tool_call", "po
15953
16550
  const HERMESAGENT_CANONICAL_EVENTS = new Set(HERMESAGENT_HOOK_EVENTS);
15954
16551
  const HERMESAGENT_NATIVE_EVENTS = new Set(HERMESAGENT_NATIVE_HOOK_EVENTS);
15955
16552
  /**
16553
+ * Whether an entry of the `hooks:` mapping is a hook-event list rather than one
16554
+ * of its non-event siblings.
16555
+ *
16556
+ * The mapping is not all events: Hermes v0.20.0 nests the outbound webhook
16557
+ * registry there as `hooks.outbound`, a list of targets (`name`, `url`,
16558
+ * `events`, `secret_env`, `matcher`, `timeout`) that rulesync neither authors
16559
+ * nor imports. A documented native event is an event whatever its value; for
16560
+ * anything else the value decides, because rulesync also emits *undocumented*
16561
+ * event names supplied through the `hermesagent.hooks` override (forward
16562
+ * compatibility), and those must stay retractable. Everything rulesync writes
16563
+ * is a non-empty list of entries carrying a string `command`, which no registry
16564
+ * entry has — `outbound` entries carry `url`/`events` instead.
16565
+ *
16566
+ * Both directions ask this one question, so import and generate cannot drift.
16567
+ * @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
16568
+ */
16569
+ function isHermesHookEventEntry(key, value) {
16570
+ if (HERMESAGENT_NATIVE_EVENTS.has(key)) return true;
16571
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => isPlainObject$1(entry) && typeof entry.command === "string");
16572
+ }
16573
+ /**
15956
16574
  * Convert the canonical hooks config into Hermes's native
15957
16575
  * `hooks: { <event>: [{ matcher?, command, timeout? }] }` shape.
15958
16576
  *
@@ -16013,7 +16631,7 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
16013
16631
  }
16014
16632
  for (const [nativeEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
16015
16633
  if (HERMESAGENT_CANONICAL_EVENTS.has(nativeEvent)) continue;
16016
- if (!HERMESAGENT_NATIVE_EVENTS.has(nativeEvent)) logger?.warn(`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.19.0; preserving it for forward compatibility.`);
16634
+ if (!HERMESAGENT_NATIVE_EVENTS.has(nativeEvent)) logger?.warn(`Hermes hook event "${nativeEvent}" is not documented by Hermes Agent v0.20.0; preserving it for forward compatibility.`);
16017
16635
  setHermesHookEntries({
16018
16636
  result,
16019
16637
  event: nativeEvent,
@@ -16035,6 +16653,7 @@ function hermesHooksToCanonical(hooks) {
16035
16653
  if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return canonical;
16036
16654
  for (const [nativeEvent, entries] of Object.entries(hooks)) {
16037
16655
  if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) continue;
16656
+ if (!isHermesHookEventEntry(nativeEvent, entries)) continue;
16038
16657
  const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;
16039
16658
  const defs = [];
16040
16659
  for (const raw of entries) {
@@ -16054,6 +16673,32 @@ function hermesHooksToCanonical(hooks) {
16054
16673
  return canonical;
16055
16674
  }
16056
16675
  /**
16676
+ * Recompute the `hooks:` mapping that is written back to `config.yaml`.
16677
+ *
16678
+ * rulesync owns the hook events inside that mapping, but not the mapping
16679
+ * itself: Hermes v0.20.0 nests the outbound webhook registry under the same key
16680
+ * as `hooks.outbound`, and it is a list of webhook targets rather than a hook
16681
+ * event, so it has no rulesync spelling and no migration path. Replacing the
16682
+ * whole mapping destroyed it on every generate. Every key that is not an event
16683
+ * ({@link isHermesHookEventEntry}) is therefore carried over from the existing
16684
+ * file, while event keys are replaced wholesale so a hook deleted from the
16685
+ * rulesync source is retracted — including one written under an undocumented
16686
+ * event name through the `hermesagent.hooks` override.
16687
+ * @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
16688
+ */
16689
+ function mergeHermesHooksBlock({ existingHooks, generatedHooks }) {
16690
+ const preserved = {};
16691
+ if (isPlainObject$1(existingHooks)) for (const [key, value] of Object.entries(existingHooks)) {
16692
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
16693
+ if (isHermesHookEventEntry(key, value)) continue;
16694
+ preserved[key] = value;
16695
+ }
16696
+ return {
16697
+ ...preserved,
16698
+ ...isPlainObject$1(generatedHooks) ? generatedHooks : {}
16699
+ };
16700
+ }
16701
+ /**
16057
16702
  * Hermes Agent shell hooks.
16058
16703
  *
16059
16704
  * Hermes Agent registers shell-command hooks under the `hooks:` key of the
@@ -16121,14 +16766,22 @@ var HermesagentHooks = class HermesagentHooks extends ToolHooks {
16121
16766
  return true;
16122
16767
  }
16123
16768
  setFileContent(fileContent) {
16769
+ const existing = parseSharedConfig({
16770
+ format: "yaml",
16771
+ fileContent
16772
+ });
16773
+ const generated = parseSharedConfig({
16774
+ format: "yaml",
16775
+ fileContent: this.fileContent
16776
+ });
16124
16777
  this.fileContent = applySharedConfigPatch({
16125
16778
  fileKey: getHermesagentConfigSharedFileKey({ global: this.global }),
16126
16779
  feature: "hooks",
16127
16780
  existingContent: fileContent,
16128
- patch: parseSharedConfig({
16129
- format: "yaml",
16130
- fileContent: this.fileContent
16131
- })
16781
+ patch: { hooks: mergeHermesHooksBlock({
16782
+ existingHooks: existing.hooks,
16783
+ generatedHooks: generated.hooks
16784
+ }) }
16132
16785
  });
16133
16786
  }
16134
16787
  toRulesyncHooks() {
@@ -16282,15 +16935,36 @@ var JunieHooks = class JunieHooks extends ToolHooks {
16282
16935
  *
16283
16936
  * `experimental.session.compacting` receives `(input, output)` and exposes no
16284
16937
  * per-invocation identifier worth matching on, so it takes `null`.
16938
+ * `chat.message` receives `(input, output)` with the prompt text living in
16939
+ * `output.parts` rather than a single matchable field, so it takes `null` too.
16285
16940
  *
16286
16941
  * @see https://opencode.ai/docs/plugins/
16287
16942
  */
16288
16943
  const NAMED_HOOK_MATCHER_SUBJECTS = {
16289
16944
  "tool.execute.before": "input.tool",
16290
16945
  "tool.execute.after": "input.tool",
16291
- "experimental.session.compacting": null
16946
+ "experimental.session.compacting": null,
16947
+ "chat.message": null
16292
16948
  };
16293
16949
  /**
16950
+ * Canonical events whose generic (`event.type`) dispatch fires more broadly
16951
+ * than the canonical event means, mapped to the extra condition the generated
16952
+ * handler gates on. Keyed by canonical event like `SHELL_EVENT_TOOL_GATES`, so
16953
+ * a second canonical event mapped onto the same dispatch does not inherit a
16954
+ * gate meant for its sibling.
16955
+ *
16956
+ * `permission.replied` fires for every reply — `once`, `always` and `reject` —
16957
+ * so the canonical `permissionDenied` handler runs only for a rejecting reply.
16958
+ *
16959
+ * Note the v1 SDK's generated `Event` typing still describes this payload as
16960
+ * `{ permissionID, response }`; the schema source, the v2 typings and the TUI's
16961
+ * live consumer all agree on `{ requestID, reply }`, so the stale codegen is
16962
+ * not followed here.
16963
+ *
16964
+ * @see https://opencode.ai/docs/plugins/
16965
+ */
16966
+ const GENERIC_EVENT_PROPERTY_GATES = { permissionDenied: "event.properties.reply === \"reject\"" };
16967
+ /**
16294
16968
  * OpenCode (and Kilo) have no shell-execution lifecycle event — the
16295
16969
  * `command.executed` event these canonical events were once mapped to is a
16296
16970
  * *slash-command* event, so a hook wired there never fired on bash commands
@@ -16335,6 +17009,7 @@ function validateAndSanitizeMatcher(matcher) {
16335
17009
  function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHandlers, genericEventHandlers }) {
16336
17010
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
16337
17011
  const shellGate = SHELL_EVENT_TOOL_GATES[canonicalEvent];
17012
+ const propertyGate = GENERIC_EVENT_PROPERTY_GATES[canonicalEvent];
16338
17013
  const toolEvent = shellGate?.toolEvent ?? eventMap[canonicalEvent];
16339
17014
  if (!toolEvent) continue;
16340
17015
  const matcherSupported = !shellGate && Object.hasOwn(NAMED_HOOK_MATCHER_SUBJECTS, toolEvent) && NAMED_HOOK_MATCHER_SUBJECTS[toolEvent] !== null;
@@ -16346,7 +17021,8 @@ function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHand
16346
17021
  handlers.push({
16347
17022
  command: def.command,
16348
17023
  matcher: def.matcher ? def.matcher : void 0,
16349
- ...shellGate ? { toolGate: shellGate.tool } : {}
17024
+ ...shellGate ? { toolGate: shellGate.tool } : {},
17025
+ ...propertyGate ? { propertyGate } : {}
16350
17026
  });
16351
17027
  }
16352
17028
  if (handlers.length > 0) {
@@ -16368,7 +17044,11 @@ function buildGenericEventBodyLines(genericEventHandlers) {
16368
17044
  isFirst = false;
16369
17045
  for (const handler of handlers) {
16370
17046
  const escapedCommand = escapeForTemplateLiteral(handler.command);
16371
- bodyLines.push(` await $\`${escapedCommand}\`;`);
17047
+ if (handler.propertyGate) {
17048
+ bodyLines.push(` if (${handler.propertyGate}) {`);
17049
+ bodyLines.push(` await $\`${escapedCommand}\`;`);
17050
+ bodyLines.push(" }");
17051
+ } else bodyLines.push(` await $\`${escapedCommand}\`;`);
16372
17052
  }
16373
17053
  bodyLines.push(" }");
16374
17054
  }
@@ -16736,13 +17416,246 @@ var KimiCodeHooks = class KimiCodeHooks extends ToolHooks {
16736
17416
  }
16737
17417
  };
16738
17418
  //#endregion
17419
+ //#region src/features/hooks/kiro-ide-hooks.ts
17420
+ /**
17421
+ * One hook entry inside the Kiro IDE v1 `hooks` array.
17422
+ *
17423
+ * `z.looseObject` keeps unknown fields added by future Kiro IDE versions, so
17424
+ * imports do not drop data they do not yet understand.
17425
+ * @see https://kiro.dev/docs/hooks/types/
17426
+ */
17427
+ const KiroIdeHookActionSchema = zod_mini.z.union([zod_mini.z.looseObject({
17428
+ type: zod_mini.z.literal("command"),
17429
+ command: zod_mini.z.optional(safeString)
17430
+ }), zod_mini.z.looseObject({
17431
+ type: zod_mini.z.literal("agent"),
17432
+ prompt: zod_mini.z.optional(safeString)
17433
+ })]);
17434
+ const KiroIdeHookEntrySchema = zod_mini.z.looseObject({
17435
+ name: zod_mini.z.optional(zod_mini.z.string()),
17436
+ description: zod_mini.z.optional(zod_mini.z.string()),
17437
+ trigger: zod_mini.z.optional(zod_mini.z.string()),
17438
+ matcher: zod_mini.z.optional(zod_mini.z.string()),
17439
+ action: zod_mini.z.optional(KiroIdeHookActionSchema),
17440
+ timeout: zod_mini.z.optional(zod_mini.z.number()),
17441
+ enabled: zod_mini.z.optional(zod_mini.z.boolean())
17442
+ });
17443
+ const KiroIdeHooksFileSchema = zod_mini.z.looseObject({
17444
+ version: zod_mini.z.optional(zod_mini.z.string()),
17445
+ hooks: zod_mini.z.optional(zod_mini.z.array(KiroIdeHookEntrySchema))
17446
+ });
17447
+ /**
17448
+ * Build the Kiro IDE hook entries for a single canonical event's definitions.
17449
+ *
17450
+ * `command`-type definitions become `{ type: "command", command }` actions and
17451
+ * `prompt`-type definitions become `{ type: "agent", prompt }` actions. Other
17452
+ * types are skipped (the {@link import("./hooks-processor.js").HooksProcessor}
17453
+ * already warns about unsupported types).
17454
+ */
17455
+ function buildKiroIdeEntriesForEvent(trigger, definitions) {
17456
+ const entries = [];
17457
+ for (const def of definitions) {
17458
+ const type = def.type ?? "command";
17459
+ let action;
17460
+ if (type === "command") {
17461
+ if (def.command === void 0) continue;
17462
+ action = {
17463
+ type: "command",
17464
+ command: def.command
17465
+ };
17466
+ } else if (type === "prompt") {
17467
+ if (def.prompt === void 0) continue;
17468
+ action = {
17469
+ type: "agent",
17470
+ prompt: def.prompt
17471
+ };
17472
+ } else continue;
17473
+ entries.push({
17474
+ name: def.name ?? trigger,
17475
+ ...def.description !== void 0 && def.description !== null && { description: def.description },
17476
+ trigger,
17477
+ ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
17478
+ action,
17479
+ ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
17480
+ enabled: def.enabled ?? true
17481
+ });
17482
+ }
17483
+ return entries;
17484
+ }
17485
+ function canonicalToKiroIdeHooks(config, overrideKey) {
17486
+ const kiroIdeSupported = new Set(KIRO_IDE_HOOK_EVENTS);
17487
+ const sharedHooks = {};
17488
+ for (const [event, defs] of Object.entries(config.hooks)) if (kiroIdeSupported.has(event)) sharedHooks[event] = defs;
17489
+ const effectiveHooks = {
17490
+ ...sharedHooks,
17491
+ ...config[overrideKey]?.hooks
17492
+ };
17493
+ const entries = [];
17494
+ for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
17495
+ const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? eventName;
17496
+ entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
17497
+ }
17498
+ return entries;
17499
+ }
17500
+ function kiroIdeHooksToCanonical(entries) {
17501
+ const canonical = {};
17502
+ for (const entry of entries) {
17503
+ if (entry.trigger === void 0 || entry.action === void 0) continue;
17504
+ const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
17505
+ if (isPrototypePollutionKey(eventName)) continue;
17506
+ const def = {};
17507
+ if (entry.action.type === "command") {
17508
+ if (!entry.action.command) continue;
17509
+ def.type = "command";
17510
+ def.command = entry.action.command;
17511
+ } else {
17512
+ if (!entry.action.prompt) continue;
17513
+ def.type = "prompt";
17514
+ def.prompt = entry.action.prompt;
17515
+ }
17516
+ if (entry.name !== void 0 && entry.name !== null) def.name = entry.name;
17517
+ if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
17518
+ if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
17519
+ if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17520
+ if (entry.enabled === false) def.enabled = false;
17521
+ (canonical[eventName] ??= []).push(def);
17522
+ }
17523
+ return canonical;
17524
+ }
17525
+ /**
17526
+ * Hooks generator for the standalone Kiro hooks format (`.kiro/hooks/*.json`
17527
+ * v1), used by the **Kiro IDE** and, since Kiro CLI 3.0, by the CLI too.
17528
+ *
17529
+ * Kiro reads structured JSON hooks from `.kiro/hooks/` (workspace) and
17530
+ * `~/.kiro/hooks/` (user). A single file may declare multiple hooks in its
17531
+ * `hooks` array, so rulesync emits every generated hook into one
17532
+ * `rulesync.json` file per scope (`{ "version": "v1", "hooks": [ ... ] }`),
17533
+ * which keeps it within the single-file hooks architecture.
17534
+ *
17535
+ * {@link import("./kiro-cli-hooks.js").KiroCliHooks} subclasses this to write
17536
+ * the same format for the `kiro-cli` target; only the deprecated `kiro` alias
17537
+ * still writes the embedded `.kiro/agents/default.json` agent-config shape,
17538
+ * which Kiro CLI 3.0 no longer reads.
17539
+ *
17540
+ * @see https://kiro.dev/docs/hooks/
17541
+ */
17542
+ var KiroIdeHooks = class extends ToolHooks {
17543
+ constructor(params) {
17544
+ super({
17545
+ ...params,
17546
+ fileContent: params.fileContent ?? JSON.stringify({
17547
+ version: "v1",
17548
+ hooks: []
17549
+ }, null, 2)
17550
+ });
17551
+ }
17552
+ /**
17553
+ * The `HooksConfig` key whose `hooks` block provides tool-specific overrides
17554
+ * for this target. {@link import("./kiro-cli-hooks.js").KiroCliHooks}
17555
+ * overrides this to `kiro-cli`.
17556
+ */
17557
+ static getOverrideKey() {
17558
+ return "kiro-ide";
17559
+ }
17560
+ static getSettablePaths(_options = {}) {
17561
+ return {
17562
+ relativeDirPath: KIRO_IDE_HOOKS_DIR_PATH,
17563
+ relativeFilePath: KIRO_IDE_HOOKS_FILE_NAME
17564
+ };
17565
+ }
17566
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
17567
+ const paths = this.getSettablePaths({ global });
17568
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({
17569
+ version: "v1",
17570
+ hooks: []
17571
+ }, null, 2);
17572
+ return new this({
17573
+ outputRoot,
17574
+ relativeDirPath: paths.relativeDirPath,
17575
+ relativeFilePath: paths.relativeFilePath,
17576
+ fileContent,
17577
+ validate
17578
+ });
17579
+ }
17580
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
17581
+ const paths = this.getSettablePaths({ global });
17582
+ const hooks = canonicalToKiroIdeHooks(rulesyncHooks.getJson(), this.getOverrideKey());
17583
+ const fileContent = JSON.stringify({
17584
+ version: "v1",
17585
+ hooks
17586
+ }, null, 2);
17587
+ return new this({
17588
+ outputRoot,
17589
+ relativeDirPath: paths.relativeDirPath,
17590
+ relativeFilePath: paths.relativeFilePath,
17591
+ fileContent,
17592
+ validate
17593
+ });
17594
+ }
17595
+ toRulesyncHooks() {
17596
+ let parsed;
17597
+ try {
17598
+ parsed = KiroIdeHooksFileSchema.parse(JSON.parse(this.getFileContent()));
17599
+ } catch (error) {
17600
+ throw new Error(`Failed to parse Kiro IDE hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
17601
+ }
17602
+ const hooks = kiroIdeHooksToCanonical(parsed.hooks ?? []);
17603
+ const overrideKey = this.constructor.getOverrideKey();
17604
+ return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
17605
+ hooks,
17606
+ overrideKey
17607
+ }), null, 2) });
17608
+ }
17609
+ validate() {
17610
+ return {
17611
+ success: true,
17612
+ error: null
17613
+ };
17614
+ }
17615
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
17616
+ return new this({
17617
+ outputRoot,
17618
+ relativeDirPath,
17619
+ relativeFilePath,
17620
+ fileContent: JSON.stringify({
17621
+ version: "v1",
17622
+ hooks: []
17623
+ }, null, 2),
17624
+ validate: false
17625
+ });
17626
+ }
17627
+ };
17628
+ //#endregion
17629
+ //#region src/features/hooks/kiro-cli-hooks.ts
17630
+ /**
17631
+ * Hooks generator for the **Kiro CLI**.
17632
+ *
17633
+ * Kiro CLI 3.0 reads the same standalone `.kiro/hooks/*.json` v1 format the
17634
+ * Kiro IDE reads, so this reuses {@link KiroIdeHooks} and only redirects the
17635
+ * tool-specific override key to `kiro-cli` (so `kiro-cli.hooks` overrides in
17636
+ * the rulesync hooks config are honored, rather than `kiro-ide.hooks`).
17637
+ *
17638
+ * The embedded `.kiro/agents/default.json` agent-hook format this target used
17639
+ * to emit is documented as not working in 3.0, so it is left to the deprecated
17640
+ * `kiro` alias ({@link import("./kiro-hooks.js").KiroHooks}).
17641
+ *
17642
+ * @see https://kiro.dev/docs/cli/v3/hooks-migration/
17643
+ * @see https://kiro.dev/docs/hooks/
17644
+ */
17645
+ var KiroCliHooks = class extends KiroIdeHooks {
17646
+ static getOverrideKey() {
17647
+ return "kiro-cli";
17648
+ }
17649
+ };
17650
+ //#endregion
16739
17651
  //#region src/features/hooks/kiro-hooks.ts
16740
17652
  /**
16741
- * Convert canonical hooks config to Kiro CLI format.
17653
+ * Convert canonical hooks config to the legacy embedded Kiro agent-config
17654
+ * format.
16742
17655
  * Filters shared hooks to KIRO_HOOK_EVENTS, merges config.kiro?.hooks,
16743
- * then maps event names and emits Kiro CLI hook arrays.
17656
+ * then maps event names and emits the agent config's hook arrays.
16744
17657
  */
16745
- /** Build the Kiro CLI hook entries for a single canonical event's definitions. */
17658
+ /** Build the agent-config hook entries for a single canonical event's definitions. */
16746
17659
  function buildKiroEntriesForEvent(definitions) {
16747
17660
  const entries = [];
16748
17661
  for (const def of definitions) {
@@ -16758,7 +17671,8 @@ function buildKiroEntriesForEvent(definitions) {
16758
17671
  }
16759
17672
  return entries;
16760
17673
  }
16761
- function canonicalToKiroHooks(config, overrideKey = "kiro") {
17674
+ function canonicalToKiroHooks(config) {
17675
+ const overrideKey = "kiro";
16762
17676
  const kiroSupported = new Set(KIRO_HOOK_EVENTS);
16763
17677
  const sharedHooks = {};
16764
17678
  for (const [event, defs] of Object.entries(config.hooks)) if (kiroSupported.has(event)) sharedHooks[event] = defs;
@@ -16776,8 +17690,8 @@ function canonicalToKiroHooks(config, overrideKey = "kiro") {
16776
17690
  return kiro;
16777
17691
  }
16778
17692
  /**
16779
- * Kiro CLI hook entry as stored in each event's array.
16780
- * Uses `z.looseObject` so that unknown fields added by future Kiro CLI
17693
+ * Hook entry as stored in each event's array of the agent config.
17694
+ * Uses `z.looseObject` so that unknown fields added by future Kiro
16781
17695
  * versions are accepted and silently ignored during import.
16782
17696
  */
16783
17697
  const KiroHookEntrySchema = zod_mini.z.looseObject({
@@ -16793,7 +17707,7 @@ function importCacheTtl(entry) {
16793
17707
  return { cacheTtl: entry.cache_ttl_seconds };
16794
17708
  }
16795
17709
  /**
16796
- * Extract hooks from Kiro CLI agent config into canonical format.
17710
+ * Extract hooks from the Kiro agent config into canonical format.
16797
17711
  */
16798
17712
  function kiroHooksToCanonical(kiroHooks) {
16799
17713
  if (kiroHooks === null || kiroHooks === void 0 || typeof kiroHooks !== "object") return {};
@@ -16821,6 +17735,17 @@ function kiroHooksToCanonical(kiroHooks) {
16821
17735
  }
16822
17736
  return canonical;
16823
17737
  }
17738
+ /**
17739
+ * Hooks generator for the deprecated `kiro` alias: the embedded hook block of
17740
+ * `.kiro/agents/default.json`.
17741
+ *
17742
+ * Kiro's hooks migration guide states this format "does not work in 3.0", so
17743
+ * the `kiro-cli` target writes the standalone `.kiro/hooks/*.json` v1 format
17744
+ * instead ({@link import("./kiro-cli-hooks.js").KiroCliHooks}). It is kept here
17745
+ * so an existing agent config still round-trips.
17746
+ *
17747
+ * @see https://kiro.dev/docs/cli/v3/hooks-migration/
17748
+ */
16824
17749
  var KiroHooks = class KiroHooks extends ToolHooks {
16825
17750
  constructor(params) {
16826
17751
  super({
@@ -16828,14 +17753,6 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16828
17753
  fileContent: params.fileContent ?? "{}"
16829
17754
  });
16830
17755
  }
16831
- /**
16832
- * The `HooksConfig` key whose `hooks` block provides tool-specific overrides
16833
- * for this target. The legacy `kiro` alias uses `kiro`; {@link import(
16834
- * "./kiro-cli-hooks.js").KiroCliHooks} overrides this to `kiro-cli`.
16835
- */
16836
- static getOverrideKey() {
16837
- return "kiro";
16838
- }
16839
17756
  isDeletable() {
16840
17757
  return false;
16841
17758
  }
@@ -16860,7 +17777,7 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16860
17777
  const paths = KiroHooks.getSettablePaths({ global });
16861
17778
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
16862
17779
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
16863
- const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson(), this.getOverrideKey());
17780
+ const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson());
16864
17781
  const fileContent = applySharedConfigPatch({
16865
17782
  fileKey: sharedConfigFileKey(paths),
16866
17783
  feature: "hooks",
@@ -16884,10 +17801,9 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16884
17801
  throw new Error(`Failed to parse Kiro hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
16885
17802
  }
16886
17803
  const hooks = kiroHooksToCanonical(agentConfig.hooks);
16887
- const overrideKey = this.constructor.getOverrideKey();
16888
17804
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
16889
17805
  hooks,
16890
- overrideKey
17806
+ overrideKey: "kiro"
16891
17807
  }), null, 2) });
16892
17808
  }
16893
17809
  validate() {
@@ -16907,222 +17823,6 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16907
17823
  }
16908
17824
  };
16909
17825
  //#endregion
16910
- //#region src/features/hooks/kiro-cli-hooks.ts
16911
- /**
16912
- * Hooks generator for the **Kiro CLI**.
16913
- *
16914
- * The Kiro CLI uses the same `.kiro/agents/default.json` agent-hook format as
16915
- * the legacy `kiro` alias, so this reuses {@link KiroHooks} and only redirects
16916
- * the tool-specific override key to `kiro-cli` (so `kiro-cli.hooks` overrides in
16917
- * the rulesync hooks config are honored, rather than the legacy `kiro.hooks`).
16918
- *
16919
- * (The Kiro IDE uses the structured `.kiro/hooks/*.json` v1 format instead; see
16920
- * {@link import("./kiro-ide-hooks.js").KiroIdeHooks}.)
16921
- */
16922
- var KiroCliHooks = class extends KiroHooks {
16923
- static getOverrideKey() {
16924
- return "kiro-cli";
16925
- }
16926
- };
16927
- //#endregion
16928
- //#region src/features/hooks/kiro-ide-hooks.ts
16929
- /**
16930
- * One hook entry inside the Kiro IDE v1 `hooks` array.
16931
- *
16932
- * `z.looseObject` keeps unknown fields added by future Kiro IDE versions, so
16933
- * imports do not drop data they do not yet understand.
16934
- * @see https://kiro.dev/docs/hooks/types/
16935
- */
16936
- const KiroIdeHookActionSchema = zod_mini.z.union([zod_mini.z.looseObject({
16937
- type: zod_mini.z.literal("command"),
16938
- command: zod_mini.z.optional(safeString)
16939
- }), zod_mini.z.looseObject({
16940
- type: zod_mini.z.literal("agent"),
16941
- prompt: zod_mini.z.optional(safeString)
16942
- })]);
16943
- const KiroIdeHookEntrySchema = zod_mini.z.looseObject({
16944
- name: zod_mini.z.optional(zod_mini.z.string()),
16945
- description: zod_mini.z.optional(zod_mini.z.string()),
16946
- trigger: zod_mini.z.optional(zod_mini.z.string()),
16947
- matcher: zod_mini.z.optional(zod_mini.z.string()),
16948
- action: zod_mini.z.optional(KiroIdeHookActionSchema),
16949
- timeout: zod_mini.z.optional(zod_mini.z.number()),
16950
- enabled: zod_mini.z.optional(zod_mini.z.boolean())
16951
- });
16952
- const KiroIdeHooksFileSchema = zod_mini.z.looseObject({
16953
- version: zod_mini.z.optional(zod_mini.z.string()),
16954
- hooks: zod_mini.z.optional(zod_mini.z.array(KiroIdeHookEntrySchema))
16955
- });
16956
- /**
16957
- * Build the Kiro IDE hook entries for a single canonical event's definitions.
16958
- *
16959
- * `command`-type definitions become `{ type: "command", command }` actions and
16960
- * `prompt`-type definitions become `{ type: "agent", prompt }` actions. Other
16961
- * types are skipped (the {@link import("./hooks-processor.js").HooksProcessor}
16962
- * already warns about unsupported types).
16963
- */
16964
- function buildKiroIdeEntriesForEvent(trigger, definitions) {
16965
- const entries = [];
16966
- for (const def of definitions) {
16967
- const type = def.type ?? "command";
16968
- let action;
16969
- if (type === "command") {
16970
- if (def.command === void 0) continue;
16971
- action = {
16972
- type: "command",
16973
- command: def.command
16974
- };
16975
- } else if (type === "prompt") {
16976
- if (def.prompt === void 0) continue;
16977
- action = {
16978
- type: "agent",
16979
- prompt: def.prompt
16980
- };
16981
- } else continue;
16982
- entries.push({
16983
- name: def.name ?? trigger,
16984
- ...def.description !== void 0 && def.description !== null && { description: def.description },
16985
- trigger,
16986
- ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
16987
- action,
16988
- ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
16989
- enabled: def.enabled ?? true
16990
- });
16991
- }
16992
- return entries;
16993
- }
16994
- function canonicalToKiroIdeHooks(config) {
16995
- const kiroIdeSupported = new Set(KIRO_IDE_HOOK_EVENTS);
16996
- const sharedHooks = {};
16997
- for (const [event, defs] of Object.entries(config.hooks)) if (kiroIdeSupported.has(event)) sharedHooks[event] = defs;
16998
- const effectiveHooks = {
16999
- ...sharedHooks,
17000
- ...config["kiro-ide"]?.hooks
17001
- };
17002
- const entries = [];
17003
- for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
17004
- const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? eventName;
17005
- entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
17006
- }
17007
- return entries;
17008
- }
17009
- function kiroIdeHooksToCanonical(entries) {
17010
- const canonical = {};
17011
- for (const entry of entries) {
17012
- if (entry.trigger === void 0 || entry.action === void 0) continue;
17013
- const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
17014
- if (isPrototypePollutionKey(eventName)) continue;
17015
- const def = {};
17016
- if (entry.action.type === "command") {
17017
- if (!entry.action.command) continue;
17018
- def.type = "command";
17019
- def.command = entry.action.command;
17020
- } else {
17021
- if (!entry.action.prompt) continue;
17022
- def.type = "prompt";
17023
- def.prompt = entry.action.prompt;
17024
- }
17025
- if (entry.name !== void 0 && entry.name !== null) def.name = entry.name;
17026
- if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
17027
- if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
17028
- if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17029
- if (entry.enabled === false) def.enabled = false;
17030
- (canonical[eventName] ??= []).push(def);
17031
- }
17032
- return canonical;
17033
- }
17034
- /**
17035
- * Hooks generator for the **Kiro IDE** (`.kiro/hooks/*.json` v1).
17036
- *
17037
- * Kiro IDE 1.0 reads structured JSON hooks from `.kiro/hooks/` (workspace) and
17038
- * `~/.kiro/hooks/` (user). A single file may declare multiple hooks in its
17039
- * `hooks` array, so rulesync emits every generated hook into one
17040
- * `rulesync.json` file per scope (`{ "version": "v1", "hooks": [ ... ] }`),
17041
- * which keeps it within the single-file hooks architecture.
17042
- *
17043
- * This is distinct from the Kiro CLI ({@link import("./kiro-cli-hooks.js").
17044
- * KiroCliHooks}), which uses the `.kiro/agents/default.json` agent-config shape.
17045
- *
17046
- * @see https://kiro.dev/docs/hooks/
17047
- */
17048
- var KiroIdeHooks = class KiroIdeHooks extends ToolHooks {
17049
- constructor(params) {
17050
- super({
17051
- ...params,
17052
- fileContent: params.fileContent ?? JSON.stringify({
17053
- version: "v1",
17054
- hooks: []
17055
- }, null, 2)
17056
- });
17057
- }
17058
- static getSettablePaths(_options = {}) {
17059
- return {
17060
- relativeDirPath: KIRO_IDE_HOOKS_DIR_PATH,
17061
- relativeFilePath: KIRO_IDE_HOOKS_FILE_NAME
17062
- };
17063
- }
17064
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
17065
- const paths = KiroIdeHooks.getSettablePaths({ global });
17066
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({
17067
- version: "v1",
17068
- hooks: []
17069
- }, null, 2);
17070
- return new KiroIdeHooks({
17071
- outputRoot,
17072
- relativeDirPath: paths.relativeDirPath,
17073
- relativeFilePath: paths.relativeFilePath,
17074
- fileContent,
17075
- validate
17076
- });
17077
- }
17078
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
17079
- const paths = KiroIdeHooks.getSettablePaths({ global });
17080
- const hooks = canonicalToKiroIdeHooks(rulesyncHooks.getJson());
17081
- const fileContent = JSON.stringify({
17082
- version: "v1",
17083
- hooks
17084
- }, null, 2);
17085
- return new KiroIdeHooks({
17086
- outputRoot,
17087
- relativeDirPath: paths.relativeDirPath,
17088
- relativeFilePath: paths.relativeFilePath,
17089
- fileContent,
17090
- validate
17091
- });
17092
- }
17093
- toRulesyncHooks() {
17094
- let parsed;
17095
- try {
17096
- parsed = KiroIdeHooksFileSchema.parse(JSON.parse(this.getFileContent()));
17097
- } catch (error) {
17098
- throw new Error(`Failed to parse Kiro IDE hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
17099
- }
17100
- const hooks = kiroIdeHooksToCanonical(parsed.hooks ?? []);
17101
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
17102
- hooks,
17103
- overrideKey: "kiro-ide"
17104
- }), null, 2) });
17105
- }
17106
- validate() {
17107
- return {
17108
- success: true,
17109
- error: null
17110
- };
17111
- }
17112
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
17113
- return new KiroIdeHooks({
17114
- outputRoot,
17115
- relativeDirPath,
17116
- relativeFilePath,
17117
- fileContent: JSON.stringify({
17118
- version: "v1",
17119
- hooks: []
17120
- }, null, 2),
17121
- validate: false
17122
- });
17123
- }
17124
- };
17125
- //#endregion
17126
17826
  //#region src/features/hooks/opencode-hooks.ts
17127
17827
  var OpencodeHooks = class OpencodeHooks extends ToolHooks {
17128
17828
  constructor(params) {
@@ -18186,6 +18886,17 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
18186
18886
  supportedHookTypes: ["command"],
18187
18887
  supportsMatcher: true
18188
18888
  }],
18889
+ ["cline", {
18890
+ class: ClineHooks,
18891
+ meta: {
18892
+ supportsProject: true,
18893
+ supportsGlobal: true,
18894
+ supportsImport: false
18895
+ },
18896
+ supportedEvents: CLINE_HOOK_EVENTS,
18897
+ supportedHookTypes: ["command"],
18898
+ supportsMatcher: false
18899
+ }],
18189
18900
  ["goose", {
18190
18901
  class: GooseHooks,
18191
18902
  meta: {
@@ -18247,12 +18958,13 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
18247
18958
  class: KiroCliHooks,
18248
18959
  meta: {
18249
18960
  supportsProject: true,
18250
- supportsGlobal: false,
18961
+ supportsGlobal: true,
18251
18962
  supportsImport: true
18252
18963
  },
18253
- supportedEvents: KIRO_HOOK_EVENTS,
18254
- supportedHookTypes: ["command"],
18255
- supportsMatcher: true
18964
+ supportedEvents: KIRO_IDE_HOOK_EVENTS,
18965
+ supportedHookTypes: ["command", "prompt"],
18966
+ supportsMatcher: true,
18967
+ passthroughOverrideEvents: true
18256
18968
  }],
18257
18969
  ["kiro-ide", {
18258
18970
  class: KiroIdeHooks,
@@ -18391,7 +19103,11 @@ var HooksProcessor = class extends FeatureProcessor {
18391
19103
  relativeFilePath: paths.relativeFilePath,
18392
19104
  global: this.global
18393
19105
  });
18394
- const list = toolHooks.isDeletable?.() !== false ? [toolHooks] : [];
19106
+ const auxiliaryFiles = await factory.class.getDeletableAuxiliaryFiles?.({
19107
+ outputRoot: this.outputRoot,
19108
+ global: this.global
19109
+ }) ?? [];
19110
+ const list = [...toolHooks.isDeletable?.() !== false ? [toolHooks] : [], ...auxiliaryFiles.filter((file) => file.isDeletable())];
18395
19111
  this.logger.debug(`Successfully loaded ${list.length} ${this.toolTarget} hooks files for deletion`);
18396
19112
  return list;
18397
19113
  }
@@ -18456,7 +19172,7 @@ var HooksProcessor = class extends FeatureProcessor {
18456
19172
  effectiveHooks
18457
19173
  });
18458
19174
  if (eventsWithUnsupportedMatcher.length > 0) this.logger.warn(`Skipped matcher hook(s) for ${this.toolTarget} (not supported): ${eventsWithUnsupportedMatcher.join(", ")}`);
18459
- const result = [await factory.class.fromRulesyncHooks({
19175
+ const toolHooks = await factory.class.fromRulesyncHooks({
18460
19176
  outputRoot: this.outputRoot,
18461
19177
  rulesyncHooks,
18462
19178
  validate: true,
@@ -18465,10 +19181,16 @@ var HooksProcessor = class extends FeatureProcessor {
18465
19181
  logger: this.logger,
18466
19182
  toolTarget: this.toolTarget
18467
19183
  })
18468
- })];
19184
+ });
19185
+ const result = [toolHooks];
18469
19186
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
18470
19187
  outputRoot: this.outputRoot,
18471
- global: this.global
19188
+ global: this.global,
19189
+ toolHooks,
19190
+ logger: withToolTargetPrefix({
19191
+ logger: this.logger,
19192
+ toolTarget: this.toolTarget
19193
+ })
18472
19194
  });
18473
19195
  if (auxiliaryFiles && auxiliaryFiles.length > 0) result.push(...auxiliaryFiles);
18474
19196
  return result;
@@ -18946,29 +19668,43 @@ var CursorIgnore = class CursorIgnore extends ToolIgnore {
18946
19668
  * `.codeiumignore` filename is read as a fallback so existing projects still
18947
19669
  * round-trip.
18948
19670
  *
19671
+ * In global mode the enterprise-wide `~/.codeium/.codeiumignore` is written
19672
+ * instead; see `DEVIN_GLOBAL_IGNORE_DIR_PATH` for why that path keeps the
19673
+ * legacy brand spelling and sits outside `~/.config/devin`.
19674
+ *
18949
19675
  * @see https://docs.devin.ai/desktop/changelog — v3.1.7 added `.devinignore`
18950
19676
  * alongside `.windsurfignore` and `.codeiumignore`.
18951
19677
  */
18952
19678
  var DevinIgnore = class DevinIgnore extends ToolIgnore {
18953
- static getSettablePaths() {
19679
+ static getSettablePaths({ global = false } = {}) {
18954
19680
  return {
18955
- relativeDirPath: ".",
18956
- relativeFilePath: DEVIN_IGNORE_FILE_NAME
19681
+ relativeDirPath: global ? DEVIN_GLOBAL_IGNORE_DIR_PATH : ".",
19682
+ relativeFilePath: global ? DEVIN_GLOBAL_IGNORE_FILE_NAME : DEVIN_IGNORE_FILE_NAME
18957
19683
  };
18958
19684
  }
18959
19685
  toRulesyncIgnore() {
18960
19686
  return this.toRulesyncIgnoreDefault();
18961
19687
  }
18962
- static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
19688
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, global = false }) {
19689
+ const paths = this.getSettablePaths({ global });
18963
19690
  return new DevinIgnore({
18964
19691
  outputRoot,
18965
- relativeDirPath: this.getSettablePaths().relativeDirPath,
18966
- relativeFilePath: this.getSettablePaths().relativeFilePath,
18967
- fileContent: rulesyncIgnore.getFileContent()
19692
+ relativeDirPath: paths.relativeDirPath,
19693
+ relativeFilePath: paths.relativeFilePath,
19694
+ fileContent: rulesyncIgnore.getFileContent(),
19695
+ global
18968
19696
  });
18969
19697
  }
18970
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
18971
- const { relativeDirPath, relativeFilePath } = this.getSettablePaths();
19698
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
19699
+ const { relativeDirPath, relativeFilePath } = this.getSettablePaths({ global });
19700
+ if (global) return new DevinIgnore({
19701
+ outputRoot,
19702
+ relativeDirPath,
19703
+ relativeFilePath,
19704
+ fileContent: await readFileContent((0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath)),
19705
+ validate,
19706
+ global
19707
+ });
18972
19708
  const primaryPath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
18973
19709
  const legacyPath = (0, node_path.join)(outputRoot, relativeDirPath, DEVIN_LEGACY_IGNORE_FILE_NAME);
18974
19710
  const resolvedFilePath = !await fileExists(primaryPath) && await fileExists(legacyPath) ? DEVIN_LEGACY_IGNORE_FILE_NAME : relativeFilePath;
@@ -18978,16 +19714,18 @@ var DevinIgnore = class DevinIgnore extends ToolIgnore {
18978
19714
  relativeDirPath,
18979
19715
  relativeFilePath: resolvedFilePath,
18980
19716
  fileContent,
18981
- validate
19717
+ validate,
19718
+ global
18982
19719
  });
18983
19720
  }
18984
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
19721
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
18985
19722
  return new DevinIgnore({
18986
19723
  outputRoot,
18987
19724
  relativeDirPath,
18988
19725
  relativeFilePath,
18989
19726
  fileContent: "",
18990
- validate: false
19727
+ validate: false,
19728
+ global
18991
19729
  });
18992
19730
  }
18993
19731
  };
@@ -19861,6 +20599,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
19861
20599
  ]);
19862
20600
  const ignoreProcessorToolTargets = [...toolIgnoreFactories.keys()];
19863
20601
  const ignoreProcessorGlobalToolTargets = [
20602
+ "devin",
19864
20603
  "kiro",
19865
20604
  "kiro-cli",
19866
20605
  "kiro-ide",
@@ -20788,6 +21527,65 @@ const RULESYNC_TO_CODEX_SCALAR_FIELD_MAP = { experimentalEnvironment: "experimen
20788
21527
  const CODEX_TO_RULESYNC_SCALAR_FIELD_MAP = Object.fromEntries(Object.entries(RULESYNC_TO_CODEX_SCALAR_FIELD_MAP).map(([canonical, codex]) => [codex, canonical]));
20789
21528
  const MAX_REMOVE_EMPTY_ENTRIES_DEPTH$1 = 32;
20790
21529
  /**
21530
+ * Canonical per-server keys Codex has no counterpart for.
21531
+ *
21532
+ * Codex's deserializer (`RawMcpServerConfig`) does not reject unknown keys, so
21533
+ * these are inert rather than fatal — but they are rulesync's own spellings and
21534
+ * only add noise to a hand-edited `config.toml`. `type`/`transport` are safe to
21535
+ * drop because Codex infers the transport from `command` versus `url`.
21536
+ * `tools` is handled separately: it is fatal rather than inert.
21537
+ * @see https://github.com/openai/codex/blob/rust-v0.146.1/codex-rs/config/src/mcp_types.rs
21538
+ */
21539
+ const CODEX_UNSUPPORTED_CANONICAL_KEYS = /* @__PURE__ */ new Set([
21540
+ "type",
21541
+ "transport",
21542
+ "alwaysAllow",
21543
+ "trust",
21544
+ "kiroAutoApprove",
21545
+ "kiroAutoBlock"
21546
+ ]);
21547
+ /**
21548
+ * Canonical millisecond timeouts and the Codex fields they translate to.
21549
+ * Codex takes both as seconds (`f64`), so the value is divided by 1000 and a
21550
+ * fractional result is emitted as-is.
21551
+ * - `timeout` → `tool_timeout_sec`: default timeout for tool calls on the server.
21552
+ * - `networkTimeout` → `startup_timeout_sec`: initialize + list-tools timeout.
21553
+ */
21554
+ const RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP = {
21555
+ timeout: "tool_timeout_sec",
21556
+ networkTimeout: "startup_timeout_sec"
21557
+ };
21558
+ const CODEX_TO_RULESYNC_TIMEOUT_FIELD_MAP = Object.fromEntries(Object.entries(RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP).map(([canonical, codex]) => [codex, canonical]));
21559
+ const MILLISECONDS_PER_SECOND = 1e3;
21560
+ /**
21561
+ * Whether a value is usable as a timeout. Codex builds a `Duration` out of both
21562
+ * timeout fields, and `Duration::try_from_secs_f64` errors on a negative value —
21563
+ * which fails the whole `config.toml`, not just the one server — so a negative
21564
+ * timeout is rejected here rather than written.
21565
+ */
21566
+ function isTimeoutValue(value) {
21567
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
21568
+ }
21569
+ /**
21570
+ * Whether a value is usable as the canonical `headers` map, whose schema is
21571
+ * `record(string, string)`. Checked in both directions so a hand-written
21572
+ * `http_headers` can never be imported into a `.rulesync/mcp.jsonc` that the
21573
+ * next generate would refuse to parse.
21574
+ */
21575
+ function isHeadersRecord(value) {
21576
+ return isPlainObject$1(value) && Object.values(value).every((entry) => typeof entry === "string");
21577
+ }
21578
+ /**
21579
+ * Whether a server config describes a Codex stdio server. Codex branches on
21580
+ * `command` first; a config carrying both `command` and `url` is classified as
21581
+ * stdio here, which matches upstream in the sense that it never reaches the
21582
+ * remote arm — upstream rejects that combination outright ("url is not
21583
+ * supported for stdio").
21584
+ */
21585
+ function isCodexStdioServer(config) {
21586
+ return config["command"] !== void 0;
21587
+ }
21588
+ /**
20791
21589
  * `env_vars` entries are either a bare variable name or `{ name, source }`,
20792
21590
  * where `source = "remote"` reads the variable from the remote executor
20793
21591
  * environment. The other renamed keys (`enabled_tools`, `disabled_tools`) stay
@@ -20845,6 +21643,75 @@ function normalizeCodexMcpServerName(name) {
20845
21643
  usedFallback: true
20846
21644
  };
20847
21645
  }
21646
+ /**
21647
+ * Translate the Codex-native per-server keys that carry a canonical
21648
+ * counterpart under a different name or unit. Returns `undefined` for a key
21649
+ * this translation does not own, leaving it to the caller's other branches.
21650
+ */
21651
+ function translateCodexOnlyKey({ key, value, config, serverName }) {
21652
+ if (key === "tools") return {};
21653
+ if (key === "http_headers") {
21654
+ if ("headers" in config) return {};
21655
+ if (isHeadersRecord(value)) return { entry: ["headers", omitPrototypePollutionKeys(value)] };
21656
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a table of string values`);
21657
+ return {};
21658
+ }
21659
+ const mappedKey = CODEX_TO_RULESYNC_TIMEOUT_FIELD_MAP[key];
21660
+ if (mappedKey) {
21661
+ if (isTimeoutValue(value)) return { entry: [mappedKey, value * MILLISECONDS_PER_SECOND] };
21662
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a non-negative number of seconds`);
21663
+ return {};
21664
+ }
21665
+ if (key === "startup_timeout_ms") {
21666
+ if ("startup_timeout_sec" in config) return {};
21667
+ if (isTimeoutValue(value)) return { entry: ["networkTimeout", value] };
21668
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a non-negative number of milliseconds`);
21669
+ return {};
21670
+ }
21671
+ }
21672
+ /**
21673
+ * Translate the canonical per-server keys that Codex spells differently, reads
21674
+ * in another unit, or cannot accept at all. Returns `undefined` for a key this
21675
+ * translation does not own.
21676
+ */
21677
+ function translateCanonicalKeyToCodex({ key, value, isStdio, serverName }) {
21678
+ if (key === "tools") {
21679
+ warnWithFallback(void 0, `[CodexCliMcp] Dropping 'tools' from MCP server "${serverName}": Codex reads it as a per-tool approval table, not a tool allowlist. Use 'enabledTools' / 'disabledTools' instead.`);
21680
+ return {};
21681
+ }
21682
+ if (CODEX_UNSUPPORTED_CANONICAL_KEYS.has(key)) return {};
21683
+ if (key === "headers") {
21684
+ if (!isHeadersRecord(value)) {
21685
+ warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key 'headers': expected a table of string values, got ${typeof value}`);
21686
+ return {};
21687
+ }
21688
+ if (isStdio) {
21689
+ warnWithFallback(void 0, `[CodexCliMcp] Dropping 'headers' from stdio MCP server "${serverName}": Codex accepts HTTP headers only on url-based servers.`);
21690
+ return {};
21691
+ }
21692
+ return { entry: ["http_headers", omitPrototypePollutionKeys(value)] };
21693
+ }
21694
+ const mappedKey = RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP[key];
21695
+ if (mappedKey) {
21696
+ if (isTimeoutValue(value)) return { entry: [mappedKey, value / MILLISECONDS_PER_SECOND] };
21697
+ warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected a non-negative number of milliseconds, got ${typeof value}`);
21698
+ return {};
21699
+ }
21700
+ }
21701
+ /**
21702
+ * Codex states no transport of its own — it infers one from `command` versus
21703
+ * `url`, which is why generate drops the canonical `type`. Restate it for a url
21704
+ * server on the way back, so a config imported from Codex reaches the adapters
21705
+ * that branch on `type` as a remote server rather than one with no transport at
21706
+ * all. `streamable_http` is Codex's only remote transport, and canonical spells
21707
+ * that `http`.
21708
+ */
21709
+ function restateCanonicalTransport(converted) {
21710
+ if (converted["type"] !== void 0) return;
21711
+ if (isCodexStdioServer(converted)) return;
21712
+ if (typeof converted["url"] !== "string") return;
21713
+ converted["type"] = "http";
21714
+ }
20848
21715
  function convertFromCodexFormat(codexMcp) {
20849
21716
  const result = {};
20850
21717
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -20852,7 +21719,15 @@ function convertFromCodexFormat(codexMcp) {
20852
21719
  const converted = {};
20853
21720
  for (const [key, value] of Object.entries(config)) {
20854
21721
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20855
- if (key === "enabled") {
21722
+ const codexOnly = translateCodexOnlyKey({
21723
+ key,
21724
+ value,
21725
+ config,
21726
+ serverName: name
21727
+ });
21728
+ if (codexOnly) {
21729
+ if (codexOnly.entry) converted[codexOnly.entry[0]] = codexOnly.entry[1];
21730
+ } else if (key === "enabled") {
20856
21731
  if (value === false) converted["disabled"] = true;
20857
21732
  } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthFromCodex(value);
20858
21733
  else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
@@ -20865,6 +21740,7 @@ function convertFromCodexFormat(codexMcp) {
20865
21740
  else warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${name}: expected a string`);
20866
21741
  } else converted[key] = value;
20867
21742
  }
21743
+ restateCanonicalTransport(converted);
20868
21744
  result[name] = converted;
20869
21745
  }
20870
21746
  return result;
@@ -20877,9 +21753,18 @@ function convertToCodexFormat(mcpServers) {
20877
21753
  const { codexName, usedFallback } = normalizeCodexMcpServerName(name);
20878
21754
  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.`);
20879
21755
  const converted = {};
21756
+ const isStdio = isCodexStdioServer(config);
20880
21757
  for (const [key, value] of Object.entries(config)) {
20881
21758
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20882
- if (key === "disabled") {
21759
+ const translated = translateCanonicalKeyToCodex({
21760
+ key,
21761
+ value,
21762
+ isStdio,
21763
+ serverName: name
21764
+ });
21765
+ if (translated) {
21766
+ if (translated.entry) converted[translated.entry[0]] = translated.entry[1];
21767
+ } else if (key === "disabled") {
20883
21768
  if (value === true) converted["enabled"] = false;
20884
21769
  } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthToCodex(value);
20885
21770
  else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
@@ -22322,7 +23207,8 @@ function resolveHermesTimeout(config) {
22322
23207
  * Copies the advanced Hermes-recognized per-server fields that have no canonical
22323
23208
  * alias — `auth` (`oauth` for OAuth 2.1/PKCE), mTLS `client_cert` (string PEM
22324
23209
  * path, or `[cert, key]`/`[cert, key, password]` list) and `client_key`,
22325
- * `connect_timeout` (seconds), and `supports_parallel_tool_calls` — verbatim
23210
+ * `connect_timeout` (seconds), `supports_parallel_tool_calls`,
23211
+ * `keepalive_interval`, and `elicitation` — verbatim
22326
23212
  * from `source` to `target`. Field names are identical on both sides (the
22327
23213
  * canonical `McpServerSchema` is a `looseObject`), so this serves export and
22328
23214
  * import alike. See the Hermes mcp-config-reference.
@@ -22383,6 +23269,14 @@ function copyHermesAdvancedFields(source, target) {
22383
23269
  target.sampling = omitPrototypePollutionKeys(structuredClone(source.sampling));
22384
23270
  copied = true;
22385
23271
  }
23272
+ if (typeof source.keepalive_interval === "number") {
23273
+ target.keepalive_interval = source.keepalive_interval;
23274
+ copied = true;
23275
+ }
23276
+ if (isPlainObject$1(source.elicitation)) {
23277
+ target.elicitation = omitPrototypePollutionKeys(structuredClone(source.elicitation));
23278
+ copied = true;
23279
+ }
22386
23280
  return copied;
22387
23281
  }
22388
23282
  /**
@@ -22426,8 +23320,10 @@ function applyHermesToolsBlock(hermesTools, server) {
22426
23320
  * `url`/`headers`, and per-server tool scoping lives under a `tools: { include,
22427
23321
  * exclude }` block (from the canonical `enabledTools`/`disabledTools`). Only
22428
23322
  * fields Hermes understands are emitted, so the shared `config.yaml` is not
22429
- * polluted with canonical-only aliases (`type`, `transport`, `httpUrl`,
22430
- * `networkTimeout`, ...).
23323
+ * polluted with canonical-only aliases (`type`, `httpUrl`, `networkTimeout`,
23324
+ * ...) — with one exception since v0.20.0: a canonical `sse` server is written
23325
+ * as Hermes's own `transport: sse`, without which Hermes would connect to it
23326
+ * over Streamable HTTP.
22431
23327
  */
22432
23328
  function convertServerToHermes(config) {
22433
23329
  const out = {};
@@ -22447,6 +23343,7 @@ function convertServerToHermes(config) {
22447
23343
  } else if (url !== void 0) {
22448
23344
  out.url = url;
22449
23345
  if (isPlainObject$1(config.headers)) out.headers = omitPrototypePollutionKeys(config.headers);
23346
+ if (config.type === "sse" || config.transport === "sse") out.transport = "sse";
22450
23347
  }
22451
23348
  if (config.disabled === true) out.enabled = false;
22452
23349
  const timeout = resolveHermesTimeout(config);
@@ -22494,6 +23391,7 @@ function convertFromHermesFormat(mcpServers) {
22494
23391
  if (isPlainObject$1(config.env)) server.env = omitPrototypePollutionKeys(config.env);
22495
23392
  if (typeof config.url === "string") server.url = config.url;
22496
23393
  if (isPlainObject$1(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
23394
+ if (typeof config.url === "string" && config.transport === "sse") server.type = "sse";
22497
23395
  if (config.enabled === false) server.disabled = true;
22498
23396
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
22499
23397
  if (isRecord$1(config.tools)) applyHermesToolsBlock(config.tools, server);
@@ -24066,6 +24964,7 @@ const REASONIX_PLUGIN_FIELDS = [
24066
24964
  "env",
24067
24965
  "url",
24068
24966
  "headers",
24967
+ "startup_timeout_seconds",
24069
24968
  "call_timeout_seconds",
24070
24969
  "tool_timeout_seconds"
24071
24970
  ];
@@ -24742,20 +25641,6 @@ function deriveTransportAllowlist(servers) {
24742
25641
  return allowlist;
24743
25642
  }
24744
25643
  //#endregion
24745
- //#region src/features/shared/vibe-config-scope.ts
24746
- /**
24747
- * Vibe selects exactly **one** persistence TOML and does not merge scopes: the
24748
- * trusted project `.vibe/config.toml` when one is discovered, otherwise
24749
- * `~/.vibe/config.toml` (single code path since v2.22.0's ConfigOrchestrator
24750
- * migration). A `--global` run that writes the home file is therefore inert in
24751
- * any project that has its own config — worth a heads-up, since the other Vibe
24752
- * surfaces (rules, hooks, agents, skills) genuinely combine scopes.
24753
- */
24754
- async function warnIfGlobalVibeConfigIsShadowed(logger) {
24755
- if (!await fileExists((0, node_path.join)(process.cwd(), ".vibe", "config.toml"))) return;
24756
- logger?.warn("Vibe reads exactly one config.toml (project .vibe/config.toml when present, otherwise ~/.vibe/config.toml — a fallback, not a merge). This project has .vibe/config.toml, so the global file written by --global is ignored here.");
24757
- }
24758
- //#endregion
24759
25644
  //#region src/features/mcp/vibe-mcp.ts
24760
25645
  const VIBE_MCP_SERVER_FIELDS = [
24761
25646
  "transport",
@@ -24824,9 +25709,8 @@ var VibeMcp = class VibeMcp extends ToolMcp {
24824
25709
  global
24825
25710
  });
24826
25711
  }
24827
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger, global = false }) {
25712
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
24828
25713
  const paths = this.getSettablePaths({ global });
24829
- if (global) await warnIfGlobalVibeConfigIsShadowed(logger);
24830
25714
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
24831
25715
  const existingContent = await readFileContentOrNull(filePath) ?? "";
24832
25716
  const existingServers = normalizeMcpServersArray(parseSharedConfig({
@@ -28481,6 +29365,81 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
28481
29365
  else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${JSON.stringify(item)}.`);
28482
29366
  return result;
28483
29367
  }
29368
+ /**
29369
+ * Assemble the Cursor CLI config to write.
29370
+ *
29371
+ * Cursor scopes the file asymmetrically: "Only permissions can be configured at
29372
+ * the project level. All other CLI settings must be set globally." So
29373
+ * `version`, `editor.vimMode`, `approvalMode` and `sandbox` belong to
29374
+ * `~/.cursor/cli-config.json` alone — writing them into `.cursor/cli.json`
29375
+ * produces keys Cursor ignores, which silently strands an authored
29376
+ * `cursor.approvalMode`.
29377
+ *
29378
+ * @see https://cursor.com/docs/cli/reference/configuration
29379
+ */
29380
+ function mergeCursorCliConfig({ settings, mergedPermissions, cursorOverride, global, filePath, logger }) {
29381
+ if (!global) {
29382
+ warnAboutGlobalOnlyOverrideKeys({
29383
+ cursorOverride,
29384
+ filePath,
29385
+ logger
29386
+ });
29387
+ return {
29388
+ ...settings,
29389
+ permissions: mergedPermissions
29390
+ };
29391
+ }
29392
+ const existingEditor = asExistingEditor({
29393
+ settings,
29394
+ filePath,
29395
+ logger
29396
+ });
29397
+ return {
29398
+ ...settings,
29399
+ ...cursorOverride,
29400
+ version: settings.version ?? 1,
29401
+ editor: {
29402
+ ...existingEditor,
29403
+ vimMode: existingEditor.vimMode ?? false
29404
+ },
29405
+ permissions: mergedPermissions
29406
+ };
29407
+ }
29408
+ /**
29409
+ * The existing `editor` object to merge rulesync's managed `vimMode` into.
29410
+ * Only called in global scope: a project config's `editor` is passed through
29411
+ * untouched, so narrowing it there would warn about a value nothing ignores.
29412
+ */
29413
+ function asExistingEditor({ settings, filePath, logger }) {
29414
+ const raw = settings.editor;
29415
+ if (raw === void 0) return {};
29416
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) return raw;
29417
+ logger?.warn(`Cursor CLI config at ${filePath} has a non-object \`editor\` field; ignoring existing editor settings.`);
29418
+ return {};
29419
+ }
29420
+ /**
29421
+ * Override keys that never reach the file from the override in any scope,
29422
+ * because rulesync re-applies its own managed value over them. Naming them in
29423
+ * the project-scope warning would wrongly promise that `--global` makes them
29424
+ * take effect.
29425
+ */
29426
+ const CURSOR_OVERRIDE_KEYS_ALWAYS_CLOBBERED = /* @__PURE__ */ new Set([
29427
+ "version",
29428
+ "editor",
29429
+ "permissions"
29430
+ ]);
29431
+ /**
29432
+ * Name the override keys that would have been written in global scope but are
29433
+ * dropped here, so the user learns the setting did not take effect and how to
29434
+ * make it.
29435
+ */
29436
+ function warnAboutGlobalOnlyOverrideKeys({ cursorOverride, filePath, logger }) {
29437
+ const strandedKeys = Object.keys(cursorOverride).filter((key) => !CURSOR_OVERRIDE_KEYS_ALWAYS_CLOBBERED.has(key)).toSorted();
29438
+ if (strandedKeys.length === 0) return;
29439
+ const names = strandedKeys.map((key) => `\`${key}\``).join(", ");
29440
+ const [wasWere, itThem] = strandedKeys.length === 1 ? ["was", "it"] : ["were", "them"];
29441
+ logger?.warn(`Cursor applies only \`permissions\` from a project config, so ${names} from the \`cursor\` override ${wasWere} not written to ${filePath}. Generate with --global to set ${itThem} in ~/.cursor/cli-config.json.`);
29442
+ }
28484
29443
  var CursorPermissions = class CursorPermissions extends ToolPermissions {
28485
29444
  constructor(params) {
28486
29445
  super({
@@ -28521,14 +29480,6 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
28521
29480
  const config = rulesyncPermissions.getJson();
28522
29481
  const { allow, deny } = convertRulesyncToCursorPermissions(config, logger);
28523
29482
  const managedTypes = new Set(Object.keys(config.permission).map((category) => toCursorType(category)));
28524
- const existingEditorRaw = settings.editor;
28525
- let existingEditor;
28526
- if (existingEditorRaw === void 0) existingEditor = {};
28527
- else if (existingEditorRaw !== null && typeof existingEditorRaw === "object" && !Array.isArray(existingEditorRaw)) existingEditor = existingEditorRaw;
28528
- else {
28529
- logger?.warn(`Cursor CLI config at ${filePath} has a non-object \`editor\` field; ignoring existing editor settings.`);
28530
- existingEditor = {};
28531
- }
28532
29483
  const existingPermissionsRaw = settings.permissions;
28533
29484
  let existingPermissions;
28534
29485
  if (existingPermissionsRaw === void 0) existingPermissions = {};
@@ -28545,17 +29496,14 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
28545
29496
  mergedPermissions.allow = mergedAllow;
28546
29497
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
28547
29498
  else delete mergedPermissions.deny;
28548
- const cursorOverride = config.cursor;
28549
- const merged = {
28550
- ...settings,
28551
- ...cursorOverride,
28552
- version: settings.version ?? 1,
28553
- editor: {
28554
- ...existingEditor,
28555
- vimMode: existingEditor.vimMode ?? false
28556
- },
28557
- permissions: mergedPermissions
28558
- };
29499
+ const merged = mergeCursorCliConfig({
29500
+ settings,
29501
+ mergedPermissions,
29502
+ cursorOverride: config.cursor ?? {},
29503
+ global,
29504
+ filePath,
29505
+ logger
29506
+ });
28559
29507
  const fileContent = JSON.stringify(merged, null, 2);
28560
29508
  return new CursorPermissions({
28561
29509
  outputRoot,
@@ -29269,14 +30217,16 @@ const CATEGORY_TO_GROK_TOOL = {
29269
30217
  edit: "Edit",
29270
30218
  write: "Edit",
29271
30219
  grep: "Grep",
29272
- webfetch: "WebFetch"
30220
+ webfetch: "WebFetch",
30221
+ websearch: "WebSearch"
29273
30222
  };
29274
30223
  const GROK_TOOL_TO_CATEGORY = {
29275
30224
  Bash: "bash",
29276
30225
  Read: "read",
29277
30226
  Edit: "edit",
29278
30227
  Grep: "grep",
29279
- WebFetch: "webfetch"
30228
+ WebFetch: "webfetch",
30229
+ WebSearch: "websearch"
29280
30230
  };
29281
30231
  const GROK_MCP_TOOL = "MCPTool";
29282
30232
  /**
@@ -29335,18 +30285,18 @@ function parseGrokEntry(entry) {
29335
30285
  *
29336
30286
  * Grok Build CLI ships a Claude-style rule system under `[permission]` in
29337
30287
  * `~/.grok/config.toml`: `allow` / `deny` / `ask` arrays of entries such as
29338
- * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`, and
29339
- * `WebFetch`, evaluated with precedence `deny > ask > allow`
30288
+ * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`,
30289
+ * `WebFetch`, and `WebSearch`, evaluated with precedence `deny > ask > allow`
29340
30290
  * (https://docs.x.ai/build/settings/reference). rulesync's canonical
29341
30291
  * per-category, per-pattern model maps almost 1:1:
29342
30292
  * - Generate: each `permission.<category>.<pattern> = allow|ask|deny` becomes
29343
30293
  * the matching Grok entry and is bucketed into the `[permission]` array for
29344
- * that action. `bash|read|edit|grep|webfetch` map to their Grok tool;
29345
- * `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*` maps to
29346
- * `MCPTool(...)` (a scoped MCP category folds its address into the
30294
+ * that action. `bash|read|edit|grep|webfetch|websearch` map to their Grok
30295
+ * tool; `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*`
30296
+ * maps to `MCPTool(...)` (a scoped MCP category folds its address into the
29347
30297
  * parentheses, so a non-`*` argument pattern on it is not represented).
29348
- * Categories with no Grok tool (`websearch`, `glob`, `notebookedit`,
29349
- * `agent`) are skipped (with a warning when they carry a `deny` rule, to
30298
+ * Categories with no Grok tool (`glob`, `notebookedit`, `agent`) are
30299
+ * skipped (with a warning when they carry a `deny` rule, to
29350
30300
  * surface the gap). When two canonical rules collapse onto the same Grok
29351
30301
  * entry with different actions (e.g. `edit` allow + `write` deny → `Edit`),
29352
30302
  * the strictest wins (`deny > ask > allow`) and a warning is logged, so the
@@ -29373,8 +30323,8 @@ function parseGrokEntry(entry) {
29373
30323
  * tools it models and (in global scope) the `[ui] permission_mode` value, while
29374
30324
  * every other key
29375
30325
  * (e.g. `[mcp_servers]`, `[permission] rules`, `[sandbox]`) and any user-authored
29376
- * entries for tools rulesync cannot model (e.g. `WebSearch`) are preserved. The
29377
- * file is never deleted.
30326
+ * entries for tool prefixes rulesync cannot model (e.g. `any`) are preserved.
30327
+ * The file is never deleted.
29378
30328
  */
29379
30329
  var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
29380
30330
  constructor(params) {
@@ -29481,7 +30431,7 @@ const ACTION_RANK = {
29481
30431
  };
29482
30432
  /**
29483
30433
  * Collect user-authored entries from an existing `[permission]` array whose
29484
- * tool prefix rulesync cannot model (e.g. `WebSearch`, `any`). Such entries are
30434
+ * tool prefix rulesync cannot model (e.g. `any`). Such entries are
29485
30435
  * preserved verbatim so replacing the arrays does not silently drop them
29486
30436
  * (mirrors the Cursor adapter's preservation of unmanaged types).
29487
30437
  */
@@ -31217,7 +32167,12 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
31217
32167
  "disabled",
31218
32168
  "visible"
31219
32169
  ];
31220
- const QWEN_OVERRIDE_SECURITY_KEYS = ["folderTrust"];
32170
+ const QWEN_OVERRIDE_SECURITY_KEYS = [
32171
+ "folderTrust",
32172
+ "allowedHttpHookUrls",
32173
+ "allowPrivateNetworkHooks"
32174
+ ];
32175
+ const QWEN_GLOBAL_ONLY_SECURITY_KEYS = ["allowPrivateNetworkHooks"];
31221
32176
  const QWEN_OVERRIDE_PERMISSIONS_KEYS = ["autoMode"];
31222
32177
  function asPlainRecord(value) {
31223
32178
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -31229,6 +32184,21 @@ function pickQwenOverrideKeys(group, keys) {
31229
32184
  for (const key of keys) if (source[key] !== void 0) picked[key] = source[key];
31230
32185
  return picked;
31231
32186
  }
32187
+ /**
32188
+ * Drop the global-only `security` keys from the override when generating project
32189
+ * settings, warning once per dropped key. Only the override copy is filtered, so
32190
+ * a value the user already wrote into the project file stays untouched.
32191
+ */
32192
+ function scopeOverrideSecurity(overrideSecurity, { global, relativeFilePath, logger }) {
32193
+ const scoped = { ...asPlainRecord(overrideSecurity) };
32194
+ if (global) return scoped;
32195
+ for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
32196
+ if (scoped[key] === void 0) continue;
32197
+ delete scoped[key];
32198
+ logger?.warn(`Qwen permissions: 'security.${key}' is only honored in user/system settings, so it is skipped for the project-scoped ${relativeFilePath}. Author it in the global scope instead.`);
32199
+ }
32200
+ return scoped;
32201
+ }
31232
32202
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31233
32203
  constructor(params) {
31234
32204
  super({
@@ -31297,10 +32267,18 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31297
32267
  ...asPlainRecord(settings.tools),
31298
32268
  ...asPlainRecord(override.tools)
31299
32269
  };
31300
- if (override?.security !== void 0) patch.security = {
31301
- ...asPlainRecord(settings.security),
31302
- ...asPlainRecord(override.security)
31303
- };
32270
+ if (override?.security !== void 0) {
32271
+ const scopedSecurity = scopeOverrideSecurity(override.security, {
32272
+ global,
32273
+ relativeFilePath: paths.relativeFilePath,
32274
+ logger
32275
+ });
32276
+ const mergedSecurity = {
32277
+ ...asPlainRecord(settings.security),
32278
+ ...scopedSecurity
32279
+ };
32280
+ if (Object.keys(mergedSecurity).length > 0) patch.security = mergedSecurity;
32281
+ }
31304
32282
  const fileContent = applySharedConfigPatch({
31305
32283
  fileKey: sharedConfigFileKey(paths),
31306
32284
  feature: "permissions",
@@ -31334,6 +32312,10 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31334
32312
  });
31335
32313
  const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
31336
32314
  const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
32315
+ for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
32316
+ if (overrideSecurity[key] === void 0) continue;
32317
+ moduleLogger.warn(`Qwen permissions: imported 'security.${key}'. Qwen Code ignores it in workspace settings but enforces it in user/system settings, so review it before generating with the global scope.`);
32318
+ }
31337
32319
  const overridePermissions = pickQwenOverrideKeys(settings.permissions, QWEN_OVERRIDE_PERMISSIONS_KEYS);
31338
32320
  const qwencodeOverride = {};
31339
32321
  if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
@@ -32509,7 +33491,6 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
32509
33491
  }
32510
33492
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
32511
33493
  const paths = this.getSettablePaths({ global });
32512
- if (global) await warnIfGlobalVibeConfigIsShadowed(logger);
32513
33494
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
32514
33495
  const existingContent = await readFileContentOrNull(filePath) ?? "";
32515
33496
  const config = parseVibeConfig(existingContent);
@@ -32791,7 +33772,9 @@ function warpSettingsDir() {
32791
33772
  * `[agents.execution_profiles.<id>]` collection:
32792
33773
  * - `command_allowlist` — commands that auto-execute.
32793
33774
  * - `command_denylist` — commands that always require permission (the denylist
32794
- * wins over the allowlist).
33775
+ * wins over the allowlist). Writing it at all replaces Warp's built-in
33776
+ * default denylist, so rulesync warns whenever it emits a non-empty one.
33777
+ * https://docs.warp.dev/cli/permissions-and-profiles/
32795
33778
  *
32796
33779
  * The legacy `[agents.profiles]` keys
32797
33780
  * (`agent_mode_command_execution_allowlist` / `denylist`) are consumed only
@@ -32889,6 +33872,7 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32889
33872
  if (mergedDeny.length > 0) profiles[DENYLIST_KEY] = mergedDeny;
32890
33873
  else delete profiles[DENYLIST_KEY];
32891
33874
  agents.profiles = profiles;
33875
+ if (mergedDeny.length > 0 && logger) logger.warn(`Warp's command_denylist replaces its built-in default denylist, which covers rm, curl, wget, eval, ssh, shells, and other risky command patterns. The ${mergedDeny.length} deny rule(s) from .rulesync/permissions.jsonc are now the whole denylist — add equivalents for the built-in patterns you want to keep.`);
32892
33876
  mergeIntoDefaultExecutionProfile({
32893
33877
  agents,
32894
33878
  mergedAllow,
@@ -34820,7 +35804,10 @@ const ClaudecodeSkillFrontmatterSchema = zod_mini.z.looseObject({
34820
35804
  shell: zod_mini.z.optional(zod_mini.z.string()),
34821
35805
  "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
34822
35806
  "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
34823
- paths: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())]))
35807
+ paths: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
35808
+ license: zod_mini.z.optional(zod_mini.z.string()),
35809
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
35810
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
34824
35811
  });
34825
35812
  /**
34826
35813
  * Builds the Claude Code SKILL.md frontmatter from a rulesync skill, carrying
@@ -34847,7 +35834,10 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
34847
35834
  hooks: section.hooks,
34848
35835
  "disable-model-invocation": resolvedDisableModelInvocation,
34849
35836
  "user-invocable": resolvedUserInvocable,
34850
- paths: section.paths
35837
+ paths: section.paths,
35838
+ license: section.license,
35839
+ compatibility: section.compatibility,
35840
+ metadata: section.metadata
34851
35841
  };
34852
35842
  const frontmatter = {
34853
35843
  name: rulesyncFrontmatter.name,
@@ -34858,6 +35848,115 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
34858
35848
  return frontmatter;
34859
35849
  }
34860
35850
  /**
35851
+ * Builds the `claudecode:` section of a rulesync skill from a Claude Code
35852
+ * SKILL.md frontmatter — the inverse of `buildClaudecodeSkillFrontmatter`, and
35853
+ * extracted for the same reason: to keep `toRulesyncSkill` under the
35854
+ * cyclomatic-complexity cap as fields are added. The truthy/defined split
35855
+ * mirrors that function exactly so the conversion stays symmetric.
35856
+ */
35857
+ function buildClaudecodeSkillSection({ frontmatter, resolvedPaths, scheduledTask }) {
35858
+ const fields = [
35859
+ [
35860
+ "when_to_use",
35861
+ frontmatter.when_to_use,
35862
+ "truthy"
35863
+ ],
35864
+ [
35865
+ "allowed-tools",
35866
+ frontmatter["allowed-tools"],
35867
+ "truthy"
35868
+ ],
35869
+ [
35870
+ "disallowed-tools",
35871
+ frontmatter["disallowed-tools"],
35872
+ "truthy"
35873
+ ],
35874
+ [
35875
+ "model",
35876
+ frontmatter.model,
35877
+ "truthy"
35878
+ ],
35879
+ [
35880
+ "effort",
35881
+ frontmatter.effort,
35882
+ "truthy"
35883
+ ],
35884
+ [
35885
+ "argument-hint",
35886
+ frontmatter["argument-hint"],
35887
+ "truthy"
35888
+ ],
35889
+ [
35890
+ "arguments",
35891
+ frontmatter.arguments,
35892
+ "defined"
35893
+ ],
35894
+ [
35895
+ "context",
35896
+ frontmatter.context,
35897
+ "truthy"
35898
+ ],
35899
+ [
35900
+ "agent",
35901
+ frontmatter.agent,
35902
+ "truthy"
35903
+ ],
35904
+ [
35905
+ "background",
35906
+ frontmatter.background,
35907
+ "defined"
35908
+ ],
35909
+ [
35910
+ "hooks",
35911
+ frontmatter.hooks,
35912
+ "defined"
35913
+ ],
35914
+ [
35915
+ "shell",
35916
+ frontmatter.shell,
35917
+ "truthy"
35918
+ ],
35919
+ [
35920
+ "disable-model-invocation",
35921
+ frontmatter["disable-model-invocation"],
35922
+ "defined"
35923
+ ],
35924
+ [
35925
+ "user-invocable",
35926
+ frontmatter["user-invocable"],
35927
+ "defined"
35928
+ ],
35929
+ [
35930
+ "scheduled-task",
35931
+ scheduledTask || void 0,
35932
+ "defined"
35933
+ ],
35934
+ [
35935
+ "paths",
35936
+ resolvedPaths,
35937
+ "defined"
35938
+ ],
35939
+ [
35940
+ "license",
35941
+ frontmatter.license,
35942
+ "defined"
35943
+ ],
35944
+ [
35945
+ "compatibility",
35946
+ frontmatter.compatibility,
35947
+ "defined"
35948
+ ],
35949
+ [
35950
+ "metadata",
35951
+ frontmatter.metadata,
35952
+ "defined"
35953
+ ]
35954
+ ];
35955
+ const section = {};
35956
+ for (const [key, value, presence] of fields) if (presence === "truthy" ? Boolean(value) : value !== void 0) section[key] = value;
35957
+ return section;
35958
+ }
35959
+ /**
34861
35960
  * Escapes the glob metacharacters in a directory path so it matches literally.
34862
35961
  * A real directory name may contain them — `app/[slug]` in a Next.js tree is
34863
35962
  * the common case, and unescaped `[slug]` reads as a bracket expression that
@@ -34978,25 +36077,11 @@ var ClaudecodeSkill = class extends ToolSkill {
34978
36077
  }
34979
36078
  toRulesyncSkill() {
34980
36079
  const frontmatter = this.getFrontmatter();
34981
- const resolvedPaths = frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath);
34982
- const claudecodeSection = {
34983
- ...frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use },
34984
- ...frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] },
34985
- ...frontmatter["disallowed-tools"] && { "disallowed-tools": frontmatter["disallowed-tools"] },
34986
- ...frontmatter.model && { model: frontmatter.model },
34987
- ...frontmatter.effort && { effort: frontmatter.effort },
34988
- ...frontmatter["argument-hint"] && { "argument-hint": frontmatter["argument-hint"] },
34989
- ...frontmatter.arguments !== void 0 && { arguments: frontmatter.arguments },
34990
- ...frontmatter.context && { context: frontmatter.context },
34991
- ...frontmatter.agent && { agent: frontmatter.agent },
34992
- ...frontmatter.background !== void 0 && { background: frontmatter.background },
34993
- ...frontmatter.hooks !== void 0 && { hooks: frontmatter.hooks },
34994
- ...frontmatter.shell && { shell: frontmatter.shell },
34995
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34996
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34997
- ...this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true },
34998
- ...resolvedPaths !== void 0 && { paths: resolvedPaths }
34999
- };
36080
+ const claudecodeSection = buildClaudecodeSkillSection({
36081
+ frontmatter,
36082
+ resolvedPaths: frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath),
36083
+ scheduledTask: this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH
36084
+ });
35000
36085
  const rulesyncFrontmatter = {
35001
36086
  name: frontmatter.name,
35002
36087
  description: frontmatter.description,
@@ -47857,19 +48942,21 @@ var OpenCodeRule = class OpenCodeRule extends ToolRule {
47857
48942
  */
47858
48943
  var PiRule = class PiRule extends ToolRule {
47859
48944
  appendSystemPrompt;
47860
- constructor({ fileContent, root, appendSystemPrompt = false, ...rest }) {
48945
+ contextFileOverride;
48946
+ constructor({ fileContent, root, appendSystemPrompt = false, contextFileOverride = false, ...rest }) {
47861
48947
  super({
47862
48948
  ...rest,
47863
48949
  fileContent,
47864
48950
  root: root ?? false
47865
48951
  });
47866
48952
  this.appendSystemPrompt = appendSystemPrompt;
48953
+ this.contextFileOverride = contextFileOverride;
47867
48954
  }
47868
- static getSettablePaths({ global = false, excludeToolDir } = {}) {
48955
+ static getSettablePaths({ global = false, excludeToolDir, contextFile } = {}) {
47869
48956
  return {
47870
48957
  root: {
47871
48958
  relativeDirPath: global ? buildToolPath(".pi", "agent", excludeToolDir) : ".",
47872
- relativeFilePath: PI_RULE_FILE_NAME
48959
+ relativeFilePath: contextFile === "override" ? PI_RULE_OVERRIDE_FILE_NAME : PI_RULE_FILE_NAME
47873
48960
  },
47874
48961
  appendSystemPrompt: {
47875
48962
  relativeDirPath: global ? buildToolPath(".pi", "agent", excludeToolDir) : ".pi",
@@ -47883,7 +48970,22 @@ var PiRule = class PiRule extends ToolRule {
47883
48970
  * `APPEND_SYSTEM.md` is cleaned up once no rule opts in anymore.
47884
48971
  */
47885
48972
  static getExtraFixedFiles({ global = false } = {}) {
47886
- return [this.getSettablePaths({ global }).appendSystemPrompt];
48973
+ return [this.getSettablePaths({ global }).appendSystemPrompt, this.getSettablePaths({
48974
+ global,
48975
+ contextFile: "override"
48976
+ }).root];
48977
+ }
48978
+ /**
48979
+ * The project-root `AGENTS.md` is written by several other targets
48980
+ * (agentsmd, codexcli, warp, devin, ...), and the root-file ownership map that
48981
+ * arbitrates a shared path only applies to `--check`. With
48982
+ * `pi.contextFile: override` Pi stops writing that file, so leaving it on the
48983
+ * orphan list would make every `pi` generate delete another target's freshly
48984
+ * written output. The global `~/.pi/agent/AGENTS.md` is Pi-exclusive and stays
48985
+ * deletable.
48986
+ */
48987
+ isDeletable() {
48988
+ return !(this.getRelativeDirPath() === "." && this.getRelativeFilePath() === "AGENTS.md");
47887
48989
  }
47888
48990
  /**
47889
48991
  * Pi appends `APPEND_SYSTEM.md` to the system prompt itself, so listing it in
@@ -47908,20 +49010,29 @@ var PiRule = class PiRule extends ToolRule {
47908
49010
  appendSystemPrompt: true
47909
49011
  });
47910
49012
  }
47911
- const relativePath = (0, node_path.join)(root.relativeDirPath, root.relativeFilePath);
49013
+ const isOverride = relativeFilePath === PI_RULE_OVERRIDE_FILE_NAME;
49014
+ const rootPaths = isOverride ? this.getSettablePaths({
49015
+ global,
49016
+ contextFile: "override"
49017
+ }).root : root;
49018
+ const relativePath = (0, node_path.join)(rootPaths.relativeDirPath, rootPaths.relativeFilePath);
47912
49019
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
47913
49020
  return new PiRule({
47914
49021
  outputRoot,
47915
- relativeDirPath: root.relativeDirPath,
47916
- relativeFilePath: root.relativeFilePath,
49022
+ relativeDirPath: rootPaths.relativeDirPath,
49023
+ relativeFilePath: rootPaths.relativeFilePath,
47917
49024
  fileContent,
47918
49025
  validate,
47919
- root: true
49026
+ root: true,
49027
+ contextFileOverride: isOverride
47920
49028
  });
47921
49029
  }
47922
49030
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
47923
- const { root, appendSystemPrompt } = this.getSettablePaths({ global });
47924
49031
  const frontmatter = rulesyncRule.getFrontmatter();
49032
+ const { root, appendSystemPrompt } = this.getSettablePaths({
49033
+ global,
49034
+ contextFile: frontmatter.pi?.contextFile
49035
+ });
47925
49036
  if (!frontmatter.root && frontmatter.pi?.systemPrompt === "append") return new PiRule({
47926
49037
  outputRoot,
47927
49038
  relativeDirPath: appendSystemPrompt.relativeDirPath,
@@ -47938,7 +49049,8 @@ var PiRule = class PiRule extends ToolRule {
47938
49049
  relativeFilePath: root.relativeFilePath,
47939
49050
  fileContent: rulesyncRule.getBody(),
47940
49051
  validate,
47941
- root: isRoot
49052
+ root: isRoot,
49053
+ contextFileOverride: frontmatter.pi?.contextFile === "override"
47942
49054
  });
47943
49055
  }
47944
49056
  toRulesyncRule() {
@@ -47953,6 +49065,18 @@ var PiRule = class PiRule extends ToolRule {
47953
49065
  },
47954
49066
  body: this.getFileContent()
47955
49067
  });
49068
+ if (this.contextFileOverride) return new RulesyncRule({
49069
+ outputRoot: process.cwd(),
49070
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
49071
+ relativeFilePath: RULESYNC_OVERVIEW_FILE_NAME,
49072
+ frontmatter: {
49073
+ root: true,
49074
+ targets: ["pi"],
49075
+ globs: ["**/*"],
49076
+ pi: { contextFile: "override" }
49077
+ },
49078
+ body: this.getFileContent()
49079
+ });
47956
49080
  return this.toRulesyncRuleDefault();
47957
49081
  }
47958
49082
  validate() {
@@ -47972,14 +49096,16 @@ var PiRule = class PiRule extends ToolRule {
47972
49096
  root: false,
47973
49097
  appendSystemPrompt: true
47974
49098
  });
47975
- const isRoot = relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === root.relativeDirPath);
49099
+ const isOverride = relativeFilePath === PI_RULE_OVERRIDE_FILE_NAME;
49100
+ const isRoot = (relativeFilePath === "AGENTS.md" || isOverride) && (relativeDirPath === "." || relativeDirPath === root.relativeDirPath);
47976
49101
  return new PiRule({
47977
49102
  outputRoot,
47978
49103
  relativeDirPath,
47979
49104
  relativeFilePath,
47980
49105
  fileContent: "",
47981
49106
  validate: false,
47982
- root: isRoot
49107
+ root: isRoot,
49108
+ contextFileOverride: isOverride
47983
49109
  });
47984
49110
  }
47985
49111
  static isTargetedByRulesyncRule(rulesyncRule) {
@@ -49452,8 +50578,9 @@ var RulesProcessor = class extends FeatureProcessor {
49452
50578
  }
49453
50579
  async convertRulesyncFilesToToolFiles(rulesyncFiles) {
49454
50580
  const rulesyncRules = rulesyncFiles.filter((file) => file instanceof RulesyncRule);
49455
- const localRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().localRoot);
49456
- const nonLocalRootRules = rulesyncRules.filter((rule) => !rule.getFrontmatter().localRoot);
50581
+ const alignedRules = this.alignPiContextFile(rulesyncRules);
50582
+ const localRootRules = alignedRules.filter((rule) => rule.getFrontmatter().localRoot);
50583
+ const nonLocalRootRules = alignedRules.filter((rule) => !rule.getFrontmatter().localRoot);
49457
50584
  const factory = this.getFactory(this.toolTarget);
49458
50585
  const { meta } = factory;
49459
50586
  const convertedRules = nonLocalRootRules.map((rulesyncRule) => {
@@ -49846,6 +50973,44 @@ As this project's AI coding tool, you must follow the additional conventions bel
49846
50973
  return [...targetedRootRules, ...nonRootRules];
49847
50974
  }
49848
50975
  /**
50976
+ * Pi reads `AGENTS.override.md` *instead of* `AGENTS.md` from a directory, and
50977
+ * non-root Pi rules are folded into whichever file the root emits. A mix of
50978
+ * opted-in and opted-out rules would therefore split the output across both
50979
+ * files and let Pi silently ignore everything in `AGENTS.md`, so the root rule
50980
+ * decides for all of them: its `pi.contextFile` is copied onto the non-root
50981
+ * rules, and the flag set only on a non-root rule is dropped with a warning.
50982
+ */
50983
+ alignPiContextFile(rules) {
50984
+ if (this.toolTarget !== "pi") return rules;
50985
+ const factory = this.getFactory(this.toolTarget);
50986
+ const targeted = rules.filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
50987
+ const rootContextFile = targeted.some((rule) => rule.getFrontmatter().root === true && rule.getFrontmatter().pi?.contextFile === "override") ? "override" : void 0;
50988
+ const mismatched = targeted.filter((rule) => rule.getFrontmatter().pi?.contextFile !== rootContextFile);
50989
+ if (mismatched.length === 0) return rules;
50990
+ if (rootContextFile === void 0) this.logger.warn(`pi.contextFile is set on ${mismatched.length} non-root rule(s) but not on the root rule, so it is ignored: Pi folds every rule body into the root context file, and emitting AGENTS.override.md for some of them would hide the rest. Set it on the root rule instead: ${formatRulePaths(mismatched)}`);
50991
+ const mismatchedSet = new Set(mismatched);
50992
+ return rules.map((rule) => {
50993
+ if (!mismatchedSet.has(rule)) return rule;
50994
+ const frontmatter = rule.getFrontmatter();
50995
+ const { contextFile: _dropped, ...pi } = frontmatter.pi ?? {};
50996
+ const nextPi = {
50997
+ ...pi,
50998
+ ...rootContextFile ? { contextFile: rootContextFile } : {}
50999
+ };
51000
+ return new RulesyncRule({
51001
+ outputRoot: rule.getOutputRoot(),
51002
+ relativeDirPath: rule.getRelativeDirPath(),
51003
+ relativeFilePath: rule.getRelativeFilePath(),
51004
+ frontmatter: {
51005
+ ...frontmatter,
51006
+ ...Object.keys(nextPi).length > 0 ? { pi: nextPi } : { pi: void 0 }
51007
+ },
51008
+ body: rule.getBody(),
51009
+ validate: false
51010
+ });
51011
+ });
51012
+ }
51013
+ /**
49849
51014
  * Implementation of abstract method from FeatureProcessor
49850
51015
  * Load tool-specific rule configurations and parse them into ToolRule instances
49851
51016
  */