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.
@@ -1,6 +1,6 @@
1
1
  import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
- import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
5
  import { parse, printParseErrorCode } from "jsonc-parser";
6
6
  import os from "node:os";
@@ -357,6 +357,7 @@ const skillsProcessorToolTargetTuple = [
357
357
  ];
358
358
  const hooksProcessorToolTargetTuple = [
359
359
  "amp",
360
+ "cline",
360
361
  "antigravity-cli",
361
362
  "antigravity-ide",
362
363
  "antigravity-plugin",
@@ -632,6 +633,29 @@ async function writeFileContent(filepath, content) {
632
633
  await ensureDir(dirname(filepath));
633
634
  await writeFile(filepath, content, "utf-8");
634
635
  }
636
+ /**
637
+ * Apply a POSIX mode to an existing file. Windows has no executable bit and
638
+ * `chmod` there only toggles the read-only flag, so the call is skipped rather
639
+ * than writing a mode the platform cannot honor.
640
+ */
641
+ async function applyFileMode(filepath, mode) {
642
+ if (process.platform === "win32") return;
643
+ await chmod(filepath, mode);
644
+ }
645
+ /**
646
+ * Restore an executable bit that went missing (interrupted run, a copy that
647
+ * dropped the mode). A file whose mode is merely stricter than `mode` — the
648
+ * user chose 0700 over 0755 — is left alone.
649
+ */
650
+ async function restoreMissingExecutableBit(filepath, mode) {
651
+ if (process.platform === "win32") return;
652
+ try {
653
+ if (((await stat(filepath)).mode & 73) !== 0) return;
654
+ } catch {
655
+ return;
656
+ }
657
+ await chmod(filepath, mode);
658
+ }
635
659
  async function writeFileBuffer(filepath, buffer) {
636
660
  await ensureDir(dirname(filepath));
637
661
  await writeFile(filepath, buffer);
@@ -1801,6 +1825,13 @@ var AiFile = class {
1801
1825
  return false;
1802
1826
  }
1803
1827
  /**
1828
+ * POSIX mode to apply after writing, or `undefined` to leave the default
1829
+ * alone. Override in subclasses whose output the tool executes directly
1830
+ * (e.g. Cline's hook scripts, which are spawned by path and therefore need
1831
+ * the executable bit).
1832
+ */
1833
+ getFileMode() {}
1834
+ /**
1804
1835
  * Returns whether this file can be deleted by rulesync.
1805
1836
  * Override in subclasses that should not be deleted (e.g., user-managed config files).
1806
1837
  */
@@ -2244,7 +2275,8 @@ const HOOK_EVENTS = [
2244
2275
  "fileChanged",
2245
2276
  "directoryAdded",
2246
2277
  "elicitation",
2247
- "elicitationResult"
2278
+ "elicitationResult",
2279
+ "sessionDelete"
2248
2280
  ];
2249
2281
  /** Hook events supported by Cursor. */
2250
2282
  const CURSOR_HOOK_EVENTS = [
@@ -2340,9 +2372,10 @@ const DEVIN_HOOK_EVENTS = [
2340
2372
  /**
2341
2373
  * Hook events supported by OpenCode.
2342
2374
  *
2343
- * `preCompact` maps to `experimental.session.compacting`, which the plugin docs
2344
- * document as a named `(input, output)` hook rather than an `event.type`
2345
- * dispatch; the other entries are all generic events.
2375
+ * `preCompact` maps to `experimental.session.compacting` and
2376
+ * `beforeSubmitPrompt` to `chat.message`, both of which the plugin docs
2377
+ * document as named `(input, output)` hooks rather than `event.type`
2378
+ * dispatches; the other entries are all generic events.
2346
2379
  *
2347
2380
  * @see https://opencode.ai/docs/plugins/
2348
2381
  */
@@ -2358,16 +2391,22 @@ const OPENCODE_HOOK_EVENTS = [
2358
2391
  "preCompact",
2359
2392
  "postCompact",
2360
2393
  "afterError",
2361
- "fileChanged"
2394
+ "fileChanged",
2395
+ "notification",
2396
+ "permissionDenied",
2397
+ "beforeSubmitPrompt"
2362
2398
  ];
2363
2399
  /**
2364
- * Hook events supported by Kilo. Identical to OpenCode: Kilo's plugin docs list
2365
- * the same event surface, including `session.compacted`, `session.error`,
2366
- * `file.watcher.updated` and the experimental compaction hook.
2400
+ * Hook events supported by Kilo. Kilo's plugin docs list the same event surface
2401
+ * as OpenCode's including `session.compacted`, `session.error`,
2402
+ * `file.watcher.updated`, `permission.replied`, `chat.message` and the
2403
+ * experimental compaction hook — with one exception: they document no TUI
2404
+ * events at all, so `tui.toast.show` (canonical `notification`) is left out
2405
+ * rather than emitted into a plugin where it may never fire.
2367
2406
  *
2368
2407
  * @see https://kilo.ai/docs/automate/extending/plugins
2369
2408
  */
2370
- const KILO_HOOK_EVENTS = OPENCODE_HOOK_EVENTS;
2409
+ const KILO_HOOK_EVENTS = OPENCODE_HOOK_EVENTS.filter((event) => event !== "notification");
2371
2410
  /**
2372
2411
  * Hook events supported by Pi Coding Agent, bridged through a generated
2373
2412
  * TypeScript extension (Pi has no static hook config file; its extension API
@@ -2405,6 +2444,29 @@ const AMP_HOOK_EVENTS = [
2405
2444
  "stop"
2406
2445
  ];
2407
2446
  /**
2447
+ * Hook events supported by Cline's file-based hooks. Cline resolves one
2448
+ * executable per lifecycle event from its hooks directory, and the event names
2449
+ * it accepts are fixed by `VALID_HOOK_TYPES` in
2450
+ * `apps/vscode/src/core/hooks/utils.ts`: `TaskStart`, `TaskResume`,
2451
+ * `TaskCancel`, `TaskComplete`, `PreToolUse`, `PostToolUse`,
2452
+ * `UserPromptSubmit`, `Notification` and `PreCompact`.
2453
+ *
2454
+ * `TaskResume` and `TaskCancel` have no canonical counterpart and stay
2455
+ * unmapped rather than being approximated by `sessionEnd` / `stop`, whose
2456
+ * semantics differ.
2457
+ *
2458
+ * @see https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts
2459
+ */
2460
+ const CLINE_HOOK_EVENTS = [
2461
+ "sessionStart",
2462
+ "preToolUse",
2463
+ "postToolUse",
2464
+ "beforeSubmitPrompt",
2465
+ "preCompact",
2466
+ "notification",
2467
+ "taskCompleted"
2468
+ ];
2469
+ /**
2408
2470
  * Hook events supported by GitHub Copilot (cloud coding agent).
2409
2471
  *
2410
2472
  * GitHub now documents an eight-event surface for `.github/hooks/*.json`:
@@ -2542,7 +2604,11 @@ const GOOSE_HOOK_EVENTS = [
2542
2604
  "beforeShellExecution",
2543
2605
  "afterShellExecution"
2544
2606
  ];
2545
- /** Hook events supported by Kiro CLI. */
2607
+ /**
2608
+ * Hook events supported by the embedded agent-config hook format, which only
2609
+ * the deprecated `kiro` alias still writes. See {@link KIRO_IDE_HOOK_EVENTS}
2610
+ * for the standalone format both Kiro products read today.
2611
+ */
2546
2612
  const KIRO_HOOK_EVENTS = [
2547
2613
  "sessionStart",
2548
2614
  "sessionEnd",
@@ -2552,15 +2618,17 @@ const KIRO_HOOK_EVENTS = [
2552
2618
  "stop"
2553
2619
  ];
2554
2620
  /**
2555
- * Hook events supported by the Kiro IDE (`.kiro/hooks/*.json` v1).
2556
- *
2557
- * Kiro IDE 1.0 exposes PascalCase triggers. rulesync maps the canonical
2558
- * lifecycle events that have a clean 1:1 IDE equivalent: `SessionStart`,
2559
- * `Stop`, `UserPromptSubmit`, `PreToolUse`, and `PostToolUse`. The IDE also
2560
- * documents file-event (`PostFileCreate`/`PostFileSave`/`PostFileDelete`) and
2561
- * spec-task (`PreTaskExec`/`PostTaskExec`) triggers that have no canonical
2562
- * equivalent; those can still be emitted verbatim via a `kiro-ide` override
2563
- * block (unknown event keys pass through unchanged).
2621
+ * Hook events supported by Kiro's standalone hooks format
2622
+ * (`.kiro/hooks/*.json` v1), which the Kiro IDE and Kiro CLI 3.0 both read.
2623
+ *
2624
+ * Kiro exposes PascalCase triggers. rulesync maps the canonical lifecycle
2625
+ * events that have a clean 1:1 equivalent: `SessionStart`, `Stop`,
2626
+ * `UserPromptSubmit`, `PreToolUse`, and `PostToolUse`. Kiro also documents
2627
+ * file-event (`PostFileCreate`/`PostFileSave`/`PostFileDelete`) and spec-task
2628
+ * (`PreTaskExec`/`PostTaskExec`) triggers that have no canonical equivalent;
2629
+ * those can still be emitted verbatim via a `kiro-ide` or `kiro-cli` override
2630
+ * block (unknown event keys pass through unchanged). There is no `SessionEnd`
2631
+ * trigger, so the canonical `sessionEnd` has no home here.
2564
2632
  * @see https://kiro.dev/docs/hooks/types/
2565
2633
  */
2566
2634
  const KIRO_IDE_HOOK_EVENTS = [
@@ -2682,7 +2750,8 @@ const QWENCODE_HOOK_EVENTS = [
2682
2750
  "instructionsLoaded",
2683
2751
  "todoCreated",
2684
2752
  "todoCompleted",
2685
- "messageDisplay"
2753
+ "messageDisplay",
2754
+ "sessionDelete"
2686
2755
  ];
2687
2756
  /**
2688
2757
  * Hook events supported by Reasonix.
@@ -2745,7 +2814,11 @@ const GROKCLI_HOOK_EVENTS = [
2745
2814
  /**
2746
2815
  * Hook events supported by Kimi Code.
2747
2816
  *
2748
- * Kimi Code also exposes `Interrupt`, which has no canonical rulesync event.
2817
+ * Kimi Code also exposes `PermissionResult`, `Interrupt`, and the four events
2818
+ * added in 0.32.0 (`TurnStarted`, `UserPromptQueued`, `TaskStarted`,
2819
+ * `SessionHeartbeat`), none of which have a canonical rulesync event. They are
2820
+ * listed in `KIMI_CODE_NATIVE_HOOK_EVENTS` so a per-tool `kimi-code` override
2821
+ * can address them by their native name.
2749
2822
  *
2750
2823
  * @see https://moonshotai.github.io/kimi-code/en/customization/hooks.html
2751
2824
  */
@@ -2781,10 +2854,27 @@ const CANONICAL_TO_KIMI_CODE_EVENT_NAMES = {
2781
2854
  preCompact: "PreCompact",
2782
2855
  postCompact: "PostCompact"
2783
2856
  };
2857
+ /**
2858
+ * Every event name Kimi Code accepts in a `[[hooks]]` entry: the ones with a
2859
+ * canonical rulesync counterpart plus the native-only ones, which are reachable
2860
+ * through a per-tool `kimi-code` override that names them directly.
2861
+ *
2862
+ * `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` were
2863
+ * added in Kimi Code 0.32.0. They stay native-only: `TaskStarted` fires when a
2864
+ * background task starts and matches on task kind, whereas the canonical
2865
+ * `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated`
2866
+ * (fired while a task is being created), so the two are not interchangeable.
2867
+ *
2868
+ * @see https://moonshotai.github.io/kimi-code/en/customization/hooks.html
2869
+ */
2784
2870
  const KIMI_CODE_NATIVE_HOOK_EVENTS = [
2785
2871
  ...Object.values(CANONICAL_TO_KIMI_CODE_EVENT_NAMES),
2786
2872
  "PermissionResult",
2787
- "Interrupt"
2873
+ "Interrupt",
2874
+ "TurnStarted",
2875
+ "UserPromptQueued",
2876
+ "TaskStarted",
2877
+ "SessionHeartbeat"
2788
2878
  ];
2789
2879
  const KIMI_CODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_KIMI_CODE_EVENT_NAMES).map(([canonical, kimiCode]) => [kimiCode, canonical]));
2790
2880
  /**
@@ -3061,14 +3151,20 @@ const CANONICAL_TO_OPENCODE_EVENT_NAMES = {
3061
3151
  stop: "session.idle",
3062
3152
  afterFileEdit: "file.edited",
3063
3153
  permissionRequest: "permission.asked",
3154
+ permissionDenied: "permission.replied",
3155
+ notification: "tui.toast.show",
3064
3156
  preCompact: "experimental.session.compacting",
3157
+ beforeSubmitPrompt: "chat.message",
3065
3158
  postCompact: "session.compacted",
3066
3159
  afterError: "session.error",
3067
3160
  fileChanged: "file.watcher.updated"
3068
3161
  };
3069
3162
  /**
3070
3163
  * Map canonical camelCase event names to Kilo dot-notation.
3071
- * (Currently identical to OpenCode)
3164
+ *
3165
+ * Shared with OpenCode: the two name the same events. The `notification` entry
3166
+ * is unreachable for Kilo because `KILO_HOOK_EVENTS` omits it, and the
3167
+ * generator emits only supported events.
3072
3168
  */
3073
3169
  const CANONICAL_TO_KILO_EVENT_NAMES = CANONICAL_TO_OPENCODE_EVENT_NAMES;
3074
3170
  /**
@@ -3106,6 +3202,16 @@ const CANONICAL_TO_AMP_EVENT_NAMES = {
3106
3202
  beforeSubmitPrompt: "agent.start",
3107
3203
  stop: "agent.end"
3108
3204
  };
3205
+ /** Map canonical hook events to Cline's `VALID_HOOK_TYPES` file names. */
3206
+ const CANONICAL_TO_CLINE_EVENT_NAMES = {
3207
+ sessionStart: "TaskStart",
3208
+ preToolUse: "PreToolUse",
3209
+ postToolUse: "PostToolUse",
3210
+ beforeSubmitPrompt: "UserPromptSubmit",
3211
+ preCompact: "PreCompact",
3212
+ notification: "Notification",
3213
+ taskCompleted: "TaskComplete"
3214
+ };
3109
3215
  /**
3110
3216
  * Map canonical camelCase event names to Copilot camelCase.
3111
3217
  */
@@ -3342,7 +3448,8 @@ const CANONICAL_TO_QWENCODE_EVENT_NAMES = {
3342
3448
  instructionsLoaded: "InstructionsLoaded",
3343
3449
  todoCreated: "TodoCreated",
3344
3450
  todoCompleted: "TodoCompleted",
3345
- messageDisplay: "MessageDisplay"
3451
+ messageDisplay: "MessageDisplay",
3452
+ sessionDelete: "SessionDelete"
3346
3453
  };
3347
3454
  /**
3348
3455
  * Map Qwen Code PascalCase event names to canonical camelCase.
@@ -4179,14 +4286,20 @@ const VibePermissionsOverrideSchema = z.looseObject({
4179
4286
  * Tool-scoped override block for Cursor CLI. Cursor's `cli.json` carries scalar
4180
4287
  * autonomy settings with no canonical permission category — `approvalMode`
4181
4288
  * (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object
4182
- * (`mode`/`networkAccess`). Fields placed here are merged into the top-level of
4183
- * `.cursor/cli.json` (project) / `~/.cursor/cli-config.json` (global) and emitted
4184
- * only for Cursor, while the shared `permission` block continues to drive the
4185
- * `permissions.allow`/`permissions.deny` arrays. Kept a `looseObject` so extra
4186
- * `cli.json` keys can be authored (they are merged verbatim on generate);
4187
- * `sandbox`'s accepted values are not documented so it passes through verbatim.
4289
+ * (`mode`/`networkAccess`). Fields placed here are merged into the top level of
4290
+ * `~/.cursor/cli-config.json` and emitted only for Cursor, while the shared
4291
+ * `permission` block continues to drive the `permissions.allow`/`permissions.deny`
4292
+ * arrays. Kept a `looseObject` so extra config keys can be authored (they are
4293
+ * merged verbatim on generate); `sandbox`'s accepted values are not documented
4294
+ * so it passes through verbatim.
4295
+ *
4296
+ * These are **global-only** settings: Cursor documents that "Only permissions
4297
+ * can be configured at the project level. All other CLI settings must be set
4298
+ * globally", so a project generate skips them with a warning instead of writing
4299
+ * keys `.cursor/cli.json` would ignore. Author them with `--global`.
4300
+ *
4188
4301
  * Note: only `approvalMode` and `sandbox` round-trip back on import — other keys
4189
- * authored here reach `cli.json` on generate but are not re-extracted.
4302
+ * authored here reach the global config on generate but are not re-extracted.
4190
4303
  *
4191
4304
  * @example
4192
4305
  * { "approvalMode": "auto-review" }
@@ -4204,7 +4317,10 @@ const CursorPermissionsOverrideSchema = z.looseObject({
4204
4317
  * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
4205
4318
  * autonomy/sandbox controls with no canonical permission category — under
4206
4319
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
4207
- * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`). It also
4320
+ * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`,
4321
+ * `allowedHttpHookUrls`, `allowPrivateNetworkHooks` — the latter is honored by
4322
+ * Qwen Code only in user/system settings, so generate skips it in project scope).
4323
+ * It also
4208
4324
  * exposes `permissions.autoMode` (the Auto Mode classifier config:
4209
4325
  * `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell` — see
4210
4326
  * https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/), which
@@ -4970,7 +5086,10 @@ const RulesyncRuleFrontmatterSchema = z.object({
4970
5086
  name: z.optional(z.string()),
4971
5087
  description: z.optional(z.string())
4972
5088
  })),
4973
- pi: z.optional(z.looseObject({ systemPrompt: z.optional(z.enum(["append"])) })),
5089
+ pi: z.optional(z.looseObject({
5090
+ systemPrompt: z.optional(z.enum(["append"])),
5091
+ contextFile: z.optional(z.enum(["override"]))
5092
+ })),
4974
5093
  takt: z.optional(z.looseObject({
4975
5094
  name: z.optional(z.string()),
4976
5095
  extends: z.optional(z.string()),
@@ -5165,7 +5284,10 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
5165
5284
  "disable-model-invocation": z.optional(z.boolean()),
5166
5285
  "user-invocable": z.optional(z.boolean()),
5167
5286
  "scheduled-task": z.optional(z.boolean()),
5168
- paths: z.optional(z.union([z.string(), z.array(z.string())]))
5287
+ paths: z.optional(z.union([z.string(), z.array(z.string())])),
5288
+ license: z.optional(z.string()),
5289
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
5290
+ metadata: z.optional(z.looseObject({}))
5169
5291
  })),
5170
5292
  codexcli: z.optional(z.looseObject({
5171
5293
  "short-description": z.optional(z.string()),
@@ -5777,13 +5899,20 @@ var FeatureProcessor = class {
5777
5899
  filePath,
5778
5900
  content: contentWithNewline
5779
5901
  })) continue;
5902
+ const fileMode = aiFile.getFileMode?.();
5780
5903
  if (fileContentsEquivalent({
5781
5904
  filePath,
5782
5905
  expected: contentWithNewline,
5783
5906
  existing: existingContent
5784
- })) continue;
5907
+ })) {
5908
+ if (fileMode !== void 0 && !this.dryRun) await restoreMissingExecutableBit(filePath, fileMode);
5909
+ continue;
5910
+ }
5785
5911
  if (this.dryRun) this.logger.info(`[DRY RUN] Would write: ${filePath}`);
5786
- else await writeFileContent(filePath, contentWithNewline);
5912
+ else {
5913
+ await writeFileContent(filePath, contentWithNewline);
5914
+ if (fileMode !== void 0) await applyFileMode(filePath, fileMode);
5915
+ }
5787
5916
  changedCount++;
5788
5917
  changedPaths.push(aiFile.getRelativePathFromCwd());
5789
5918
  }
@@ -7144,10 +7273,16 @@ const SHARED_CONFIG_OWNERSHIP = {
7144
7273
  ".config/goose/config.yaml": {
7145
7274
  format: "yaml",
7146
7275
  invalidRootPolicy: "error",
7147
- features: { mcp: {
7148
- kind: "replace-owned-keys",
7149
- ownedKeys: ["extensions"]
7150
- } }
7276
+ features: {
7277
+ mcp: {
7278
+ kind: "replace-owned-keys",
7279
+ ownedKeys: ["extensions"]
7280
+ },
7281
+ commands: {
7282
+ kind: "replace-owned-keys",
7283
+ ownedKeys: ["slash_commands"]
7284
+ }
7285
+ }
7151
7286
  },
7152
7287
  [CODEXCLI_CONFIG_SHARED_FILE_KEY]: {
7153
7288
  format: "toml",
@@ -7851,6 +7986,22 @@ var ChecksProcessor = class extends FeatureProcessor {
7851
7986
  }
7852
7987
  };
7853
7988
  //#endregion
7989
+ //#region src/constants/goose-paths.ts
7990
+ const GOOSE_DIR = ".goose";
7991
+ const GOOSE_GLOBAL_DIR = join(".config", "goose");
7992
+ const GOOSE_RULE_FILE_NAME = ".goosehints";
7993
+ const GOOSE_MCP_FILE_NAME = "config.yaml";
7994
+ const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
7995
+ const GOOSE_HOOKS_DIR_PATH = join(".agents", "plugins", "rulesync", "hooks");
7996
+ const GOOSE_HOOKS_FILE_NAME = "hooks.json";
7997
+ const GOOSE_PLUGIN_MCP_DIR_PATH = join(".agents", "plugins", "rulesync");
7998
+ const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
7999
+ const GOOSE_SKILLS_DIR_PATH = join(GOOSE_DIR, "skills");
8000
+ const GOOSE_RECIPES_DIR_PATH = join(GOOSE_DIR, "recipes");
8001
+ const GOOSE_GLOBAL_RECIPES_DIR_PATH = join(GOOSE_GLOBAL_DIR, "recipes");
8002
+ const GOOSE_AGENTS_DIR_PATH = join(GOOSE_DIR, "agents");
8003
+ const GOOSE_GLOBAL_AGENTS_DIR_PATH = join(GOOSE_GLOBAL_DIR, "agents");
8004
+ //#endregion
7854
8005
  //#region src/utils/tool-home.ts
7855
8006
  /**
7856
8007
  * Where the rulesync-side source files of a tool with a home override belong.
@@ -8792,6 +8943,9 @@ const CLINE_MCP_DIR_PATH = join(CLINE_DIR, "data", "settings");
8792
8943
  const CLINE_MCP_FILE_NAME = "cline_mcp_settings.json";
8793
8944
  const CLINE_PERMISSIONS_FILE_NAME = "command-permissions.json";
8794
8945
  const CLINE_IGNORE_FILE_NAME = ".clineignore";
8946
+ const CLINE_HOOKS_DIR_PATH = join(CLINERULES_DIR, "hooks");
8947
+ const CLINE_HOOKS_GLOBAL_DIR_PATH = join("Documents", "Cline", "Hooks");
8948
+ const CLINE_HOOKS_MANIFEST_FILE_NAME = "rulesync-hooks.json";
8795
8949
  //#endregion
8796
8950
  //#region src/features/commands/cline-command.ts
8797
8951
  var ClineCommand = class ClineCommand extends ToolCommand {
@@ -9253,6 +9407,8 @@ const DEVIN_HOOKS_V1_FILE_NAME = "hooks.v1.json";
9253
9407
  const DEVIN_GLOBAL_AGENTS_FILE_NAME = "AGENTS.md";
9254
9408
  const DEVIN_IGNORE_FILE_NAME = ".devinignore";
9255
9409
  const DEVIN_LEGACY_IGNORE_FILE_NAME = ".codeiumignore";
9410
+ const DEVIN_GLOBAL_IGNORE_DIR_PATH = ".codeium";
9411
+ const DEVIN_GLOBAL_IGNORE_FILE_NAME = DEVIN_LEGACY_IGNORE_FILE_NAME;
9256
9412
  //#endregion
9257
9413
  //#region src/features/commands/command-skill-ownership.ts
9258
9414
  /**
@@ -9501,24 +9657,99 @@ var FactorydroidCommand = class FactorydroidCommand extends ToolCommand {
9501
9657
  }
9502
9658
  };
9503
9659
  //#endregion
9504
- //#region src/constants/goose-paths.ts
9505
- const GOOSE_DIR = ".goose";
9506
- const GOOSE_GLOBAL_DIR = join(".config", "goose");
9507
- const GOOSE_RULE_FILE_NAME = ".goosehints";
9508
- const GOOSE_MCP_FILE_NAME = "config.yaml";
9509
- const GOOSE_PERMISSIONS_FILE_NAME = "permission.yaml";
9510
- const GOOSE_HOOKS_DIR_PATH = join(".agents", "plugins", "rulesync", "hooks");
9511
- const GOOSE_HOOKS_FILE_NAME = "hooks.json";
9512
- const GOOSE_PLUGIN_MCP_DIR_PATH = join(".agents", "plugins", "rulesync");
9513
- const GOOSE_PLUGIN_MCP_FILE_NAME = ".mcp.json";
9514
- const GOOSE_SKILLS_DIR_PATH = join(GOOSE_DIR, "skills");
9515
- const GOOSE_RECIPES_DIR_PATH = join(GOOSE_DIR, "recipes");
9516
- const GOOSE_GLOBAL_RECIPES_DIR_PATH = join(GOOSE_GLOBAL_DIR, "recipes");
9517
- const GOOSE_AGENTS_DIR_PATH = join(GOOSE_DIR, "agents");
9518
- const GOOSE_GLOBAL_AGENTS_DIR_PATH = join(GOOSE_GLOBAL_DIR, "agents");
9519
- //#endregion
9520
9660
  //#region src/features/commands/goose-command.ts
9521
9661
  const RECIPE_VERSION = "1.0.0";
9662
+ const SLASH_COMMANDS_KEY = "slash_commands";
9663
+ const GOOSE_GLOBAL_RECIPES_POSIX_DIR = toPosixPath(GOOSE_GLOBAL_RECIPES_DIR_PATH);
9664
+ function slashCommandEntry({ outputRoot, relativeFilePath }) {
9665
+ const fileName = basename(toPosixPath(relativeFilePath));
9666
+ return {
9667
+ command: fileName.replace(/\.ya?ml$/, "").toLowerCase(),
9668
+ recipe_path: join(outputRoot, GOOSE_GLOBAL_RECIPES_DIR_PATH, fileName)
9669
+ };
9670
+ }
9671
+ /**
9672
+ * Whether an existing `slash_commands` entry points at a recipe rulesync owns:
9673
+ * a direct child of the global recipes directory. Anything else — a recipe
9674
+ * elsewhere on disk, or a sub-recipe under `recipes/subagents/` — belongs to
9675
+ * the user and is carried over untouched. A path that cannot be resolved into
9676
+ * the managed directory is preserved rather than claimed.
9677
+ */
9678
+ function isManagedRecipePath(value) {
9679
+ if (typeof value !== "string") return false;
9680
+ const segments = toPosixPath(value).replace(/^~\//, "").split("/");
9681
+ const fileName = segments.pop();
9682
+ if (fileName === void 0 || fileName === "") return false;
9683
+ const dirSegments = GOOSE_GLOBAL_RECIPES_POSIX_DIR.split("/");
9684
+ return segments.length >= dirSegments.length && segments.slice(-dirSegments.length).join("/") === GOOSE_GLOBAL_RECIPES_POSIX_DIR;
9685
+ }
9686
+ /**
9687
+ * Whether a config file carries a registration rulesync owns. Rewriting a
9688
+ * config that holds none of them would reformat the user's file (comments and
9689
+ * all) for nothing, so both writers check this first.
9690
+ */
9691
+ function hasManagedGooseSlashCommands(fileContent) {
9692
+ const existing = parseSharedConfig({
9693
+ format: "yaml",
9694
+ fileContent
9695
+ })[SLASH_COMMANDS_KEY];
9696
+ return Array.isArray(existing) && existing.some((entry) => isRecord$1(entry) && isManagedRecipePath(entry.recipe_path));
9697
+ }
9698
+ /**
9699
+ * Recompute `slash_commands` from the entries rulesync generates: user entries
9700
+ * pointing outside the managed recipes directory are carried over, entries
9701
+ * inside it are replaced (so a deleted command's registration is retracted),
9702
+ * and the key is dropped entirely when nothing is left.
9703
+ */
9704
+ function getGooseSlashCommandsConfigContent({ currentContent, entries }) {
9705
+ const config = parseSharedConfig({
9706
+ format: "yaml",
9707
+ fileContent: currentContent
9708
+ });
9709
+ const next = [...(Array.isArray(config[SLASH_COMMANDS_KEY]) ? config[SLASH_COMMANDS_KEY] : []).filter((entry) => !isRecord$1(entry) || !isManagedRecipePath(entry.recipe_path)), ...entries];
9710
+ return applySharedConfigPatch({
9711
+ fileKey: sharedConfigFileKey({
9712
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9713
+ relativeFilePath: GOOSE_MCP_FILE_NAME
9714
+ }),
9715
+ feature: "commands",
9716
+ existingContent: currentContent,
9717
+ patch: { [SLASH_COMMANDS_KEY]: next.length > 0 ? next : void 0 }
9718
+ });
9719
+ }
9720
+ /**
9721
+ * The Goose user `config.yaml`, carrying the `slash_commands` registrations for
9722
+ * the generated recipes. The file is shared with the user's own settings, so it
9723
+ * is always merged into rather than replaced.
9724
+ */
9725
+ var GooseCommandConfigFile = class extends ToolFile {
9726
+ entries;
9727
+ constructor(params) {
9728
+ super(params);
9729
+ this.entries = params.entries;
9730
+ }
9731
+ validate() {
9732
+ return {
9733
+ success: true,
9734
+ error: null
9735
+ };
9736
+ }
9737
+ shouldMergeExistingFileContent() {
9738
+ return true;
9739
+ }
9740
+ setFileContent(newFileContent) {
9741
+ super.setFileContent(getGooseSlashCommandsConfigContent({
9742
+ currentContent: newFileContent,
9743
+ entries: this.entries
9744
+ }));
9745
+ }
9746
+ getFileContent() {
9747
+ return getGooseSlashCommandsConfigContent({
9748
+ currentContent: super.getFileContent(),
9749
+ entries: this.entries
9750
+ });
9751
+ }
9752
+ };
9522
9753
  /**
9523
9754
  * Goose recipe files are reusable YAML workflow documents. A recipe requires
9524
9755
  * `version`, `title`, and `description`, plus at least one of `instructions` /
@@ -9553,6 +9784,40 @@ var GooseCommand = class GooseCommand extends ToolCommand {
9553
9784
  static getSettablePaths({ global = false } = {}) {
9554
9785
  return { relativeDirPath: global ? GOOSE_GLOBAL_RECIPES_DIR_PATH : GOOSE_RECIPES_DIR_PATH };
9555
9786
  }
9787
+ /**
9788
+ * The user `config.yaml` holding the `slash_commands` registrations. Global
9789
+ * scope only — Goose has no project-level registration surface.
9790
+ */
9791
+ static getExtraSharedWritePaths({ global = false } = {}) {
9792
+ if (!global) return [];
9793
+ return [{
9794
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9795
+ relativeFilePath: GOOSE_MCP_FILE_NAME
9796
+ }];
9797
+ }
9798
+ /**
9799
+ * Register the generated recipes as slash commands. The config file is also
9800
+ * emitted when no command is generated but the existing file still carries
9801
+ * managed registrations, so removing the last command retracts them instead of
9802
+ * leaving `/name` pointing at a deleted recipe.
9803
+ */
9804
+ static async getAuxiliaryFiles({ toolCommands, outputRoot = process.cwd(), global = false, forDeletion = false }) {
9805
+ if (!global || forDeletion) return [];
9806
+ const entries = toolCommands.map((command) => slashCommandEntry({
9807
+ outputRoot,
9808
+ relativeFilePath: command.getRelativeFilePath()
9809
+ }));
9810
+ const existingContent = await readFileContentOrNull(join(outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME));
9811
+ if (entries.length === 0 && !hasManagedGooseSlashCommands(existingContent ?? "")) return [];
9812
+ return [new GooseCommandConfigFile({
9813
+ outputRoot,
9814
+ relativeDirPath: GOOSE_GLOBAL_DIR,
9815
+ relativeFilePath: GOOSE_MCP_FILE_NAME,
9816
+ fileContent: existingContent ?? "",
9817
+ entries,
9818
+ global
9819
+ })];
9820
+ }
9556
9821
  parseRecipeContent(content) {
9557
9822
  const where = join(this.relativeDirPath, this.relativeFilePath);
9558
9823
  let parsed;
@@ -11038,8 +11303,9 @@ const KIRO_SETTINGS_DIR_PATH = join(KIRO_DIR, "settings");
11038
11303
  const KIRO_AGENTS_DIR_PATH = join(KIRO_DIR, "agents");
11039
11304
  const KIRO_HOOKS_FILE_NAME = "default.json";
11040
11305
  /**
11041
- * Kiro IDE 1.0 stores hooks as structured JSON files in `.kiro/hooks/`
11042
- * (workspace) and `~/.kiro/hooks/` (user). A single file may declare multiple
11306
+ * Kiro stores hooks as structured JSON files in `.kiro/hooks/` (workspace) and
11307
+ * `~/.kiro/hooks/` (user) the format the IDE reads and the one Kiro CLI 3.0
11308
+ * migrated to. A single file may declare multiple
11043
11309
  * hooks in its `hooks` array, so rulesync emits all generated hooks into one
11044
11310
  * `rulesync.json` file per scope.
11045
11311
  * @see https://kiro.dev/docs/hooks/
@@ -11382,6 +11648,7 @@ const PI_EXTENSIONS_DIR_PATH = join(".pi", "extensions");
11382
11648
  const PI_PROMPTS_DIR_PATH = join(".pi", "prompts");
11383
11649
  const PI_SKILLS_DIR_PATH = join(".pi", "skills");
11384
11650
  const PI_RULE_FILE_NAME = "AGENTS.md";
11651
+ const PI_RULE_OVERRIDE_FILE_NAME = "AGENTS.override.md";
11385
11652
  const PI_APPEND_SYSTEM_FILE_NAME = "APPEND_SYSTEM.md";
11386
11653
  const PI_HOOKS_FILE_NAME = "rulesync-hooks.ts";
11387
11654
  //#endregion
@@ -12873,6 +13140,7 @@ var CommandsProcessor = class extends FeatureProcessor {
12873
13140
  }));
12874
13141
  const shouldDisableHermesCommandsPlugin = this.toolTarget === "hermesagent" && existingFiles.some((file) => file.getFilePath() === ownershipPath) && !generatedFiles.some((file) => file.getFilePath() === ownershipPath);
12875
13142
  let changedCount = await super.removeOrphanAiFiles(existingFiles, generatedFiles);
13143
+ changedCount += await this.retractGooseSlashCommands(generatedFiles);
12876
13144
  if (!shouldDisableHermesCommandsPlugin) return changedCount;
12877
13145
  const configPath = join(this.outputRoot, getHermesagentRelativeFilePath({
12878
13146
  global: this.global,
@@ -12891,6 +13159,27 @@ var CommandsProcessor = class extends FeatureProcessor {
12891
13159
  return changedCount;
12892
13160
  }
12893
13161
  /**
13162
+ * Drop the `slash_commands` registrations when no Goose recipe is generated
13163
+ * any more. `GooseCommand.getAuxiliaryFiles` handles every other case, but it
13164
+ * is not reached when the whole feature has no source files left (`--delete`
13165
+ * removes the recipes there), which would strand `/name` on a deleted recipe.
13166
+ */
13167
+ async retractGooseSlashCommands(generatedFiles) {
13168
+ if (this.toolTarget !== "goose" || !this.global) return 0;
13169
+ if (generatedFiles.some((file) => file instanceof GooseCommand)) return 0;
13170
+ const configPath = join(this.outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME);
13171
+ const currentContent = await readFileContentOrNull(configPath);
13172
+ if (currentContent === null || !hasManagedGooseSlashCommands(currentContent)) return 0;
13173
+ const nextContent = getGooseSlashCommandsConfigContent({
13174
+ currentContent,
13175
+ entries: []
13176
+ });
13177
+ if (nextContent === currentContent) return 0;
13178
+ if (this.dryRun) this.logger.info(`[DRY RUN] Would write: ${configPath}`);
13179
+ else await writeFileContent(configPath, nextContent);
13180
+ return 1;
13181
+ }
13182
+ /**
12894
13183
  * Implementation of abstract method from FeatureProcessor
12895
13184
  * Return the tool targets that this processor supports
12896
13185
  */
@@ -13059,6 +13348,14 @@ var ToolHooks = class extends ToolFile {
13059
13348
  static async getAuxiliaryFiles(_params) {
13060
13349
  return [];
13061
13350
  }
13351
+ /**
13352
+ * Extra files the deletion sweep may remove, for adapters that write more
13353
+ * than their settable path. Kept separate from {@link getAuxiliaryFiles},
13354
+ * which may legitimately return a shared user-owned config file.
13355
+ */
13356
+ static async getDeletableAuxiliaryFiles(_params) {
13357
+ return [];
13358
+ }
13062
13359
  };
13063
13360
  //#endregion
13064
13361
  //#region src/features/hooks/amp-hooks.ts
@@ -14341,8 +14638,7 @@ const CLAUDE_CONVERTER_CONFIG = {
14341
14638
  "teammateIdle",
14342
14639
  "cwdChanged",
14343
14640
  "beforeSubmitPrompt",
14344
- "stop",
14345
- "directoryAdded"
14641
+ "stop"
14346
14642
  ]),
14347
14643
  supportedHookTypes: /* @__PURE__ */ new Set([
14348
14644
  "command",
@@ -14516,6 +14812,281 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
14516
14812
  }
14517
14813
  };
14518
14814
  //#endregion
14815
+ //#region src/features/hooks/cline-hooks-generator.ts
14816
+ /**
14817
+ * Marker line every generated hook script carries. Cline resolves hooks by
14818
+ * exact event name from a directory users also hand-author scripts in, so the
14819
+ * marker is what tells a rulesync-owned script apart from a user's own: only
14820
+ * files carrying it are rewritten or cleaned up.
14821
+ */
14822
+ const CLINE_HOOK_SCRIPT_MARKER = "rulesync-owned: cline-hooks";
14823
+ /** Exit code a hook command uses to cancel the task (Claude Code convention). */
14824
+ const CANCEL_EXIT_CODE = 2;
14825
+ function sanitizeCommand(command) {
14826
+ let sanitized = command;
14827
+ for (const char of CONTROL_CHARS) sanitized = sanitized.replaceAll(char, "");
14828
+ return sanitized;
14829
+ }
14830
+ /** Single-quote a string for POSIX shells. */
14831
+ function shellQuote(value) {
14832
+ return `'${value.replaceAll("'", `'\\''`)}'`;
14833
+ }
14834
+ /** Single-quote a string for PowerShell. */
14835
+ function powerShellQuote(value) {
14836
+ return `'${value.replaceAll("'", "''")}'`;
14837
+ }
14838
+ function collectClineHookCommands({ effectiveHooks, eventMap }) {
14839
+ const commandsByEvent = {};
14840
+ for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
14841
+ const clineEvent = eventMap[canonicalEvent];
14842
+ if (!clineEvent) continue;
14843
+ const commands = definitions.filter((definition) => (definition.type ?? "command") === "command" && definition.command).map((definition) => sanitizeCommand(definition.command)).filter((command) => command.trim() !== "");
14844
+ if (commands.length === 0) continue;
14845
+ const existing = commandsByEvent[clineEvent];
14846
+ if (existing) existing.push(...commands);
14847
+ else commandsByEvent[clineEvent] = commands;
14848
+ }
14849
+ return commandsByEvent;
14850
+ }
14851
+ /**
14852
+ * A POSIX wrapper script for one Cline hook event.
14853
+ *
14854
+ * Cline spawns the file itself (`spawn(scriptPath, [], { shell: true })`), feeds
14855
+ * the event payload on stdin and reads a JSON result from stdout. The wrapper
14856
+ * therefore forwards the payload to each configured command in order and
14857
+ * translates the exit codes: `2` cancels the task, any other failure is
14858
+ * reported through `errorMessage` without cancelling, mirroring how the
14859
+ * canonical `command` hook type behaves for the tools that support blocking.
14860
+ */
14861
+ function generateClineHookScript({ event, commands }) {
14862
+ const lines = [
14863
+ "#!/bin/bash",
14864
+ `# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
14865
+ `# ${CLINE_HOOK_SCRIPT_MARKER}`,
14866
+ "",
14867
+ "payload=$(cat)",
14868
+ "cancel=false",
14869
+ "error_message=''",
14870
+ ""
14871
+ ];
14872
+ for (const command of commands) {
14873
+ const quoted = shellQuote(command);
14874
+ 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", "");
14875
+ }
14876
+ 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")"`, "");
14877
+ return lines.join("\n");
14878
+ }
14879
+ /**
14880
+ * The PowerShell twin of {@link generateClineHookScript}. On Windows Cline
14881
+ * resolves only `<Event>.ps1` and runs it through `powershell -File`, so both
14882
+ * spellings are written and the platform picks one.
14883
+ */
14884
+ function generateClineHookPowerShellScript({ event, commands }) {
14885
+ const lines = [
14886
+ `# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
14887
+ `# ${CLINE_HOOK_SCRIPT_MARKER}`,
14888
+ "",
14889
+ "$payload = [Console]::In.ReadToEnd()",
14890
+ "$cancel = $false",
14891
+ "$errorMessage = ''",
14892
+ ""
14893
+ ];
14894
+ 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", " }", "}", "");
14895
+ lines.push("@{", " cancel = $cancel", " contextModification = \"\"", " errorMessage = $errorMessage", "} | ConvertTo-Json -Compress", "");
14896
+ return lines.join("\n");
14897
+ }
14898
+ //#endregion
14899
+ //#region src/features/hooks/cline-hooks.ts
14900
+ /** Mode Cline's hook scripts need: it spawns the file itself on Unix. */
14901
+ const HOOK_SCRIPT_MODE = 493;
14902
+ /** The only file names this adapter ever writes into the hooks directory. */
14903
+ const MANAGED_EVENT_NAMES = new Set(Object.values(CANONICAL_TO_CLINE_EVENT_NAMES));
14904
+ /**
14905
+ * Read the manifest of a previous run. Event names are filtered against the
14906
+ * names this adapter emits: the manifest is a file in the repository, and
14907
+ * anything else there would otherwise be turned into a path — an executable one
14908
+ * — that rulesync writes.
14909
+ */
14910
+ function parseManifest(fileContent) {
14911
+ try {
14912
+ const parsed = JSON.parse(fileContent);
14913
+ if (!isRecord$1(parsed) || !isStringArray$1(parsed.events)) return null;
14914
+ return {
14915
+ generatedBy: "rulesync",
14916
+ events: parsed.events.filter((event) => MANAGED_EVENT_NAMES.has(event))
14917
+ };
14918
+ } catch {
14919
+ return null;
14920
+ }
14921
+ }
14922
+ /** One generated hook script. Written executable so Cline can spawn it. */
14923
+ var ClineHookScript = class extends ToolFile {
14924
+ getFileMode() {
14925
+ return this.getRelativeFilePath().endsWith(".ps1") ? void 0 : HOOK_SCRIPT_MODE;
14926
+ }
14927
+ validate() {
14928
+ return {
14929
+ success: true,
14930
+ error: null
14931
+ };
14932
+ }
14933
+ };
14934
+ /**
14935
+ * Hooks adapter for Cline's file-based hooks.
14936
+ *
14937
+ * Cline (VS Code extension / Cline Desktop) resolves one executable per
14938
+ * lifecycle event from `<project>/.clinerules/hooks/` or the global
14939
+ * `~/Documents/Cline/Hooks/`, named exactly after the event: the extensionless
14940
+ * name on Unix, `<Event>.ps1` on Windows. The script receives the event payload
14941
+ * as JSON on stdin and answers with `{"cancel": …, "contextModification": …,
14942
+ * "errorMessage": …}` on stdout. rulesync emits a wrapper script per configured
14943
+ * event in both spellings, plus a `rulesync-hooks.json` manifest naming the
14944
+ * scripts it owns.
14945
+ *
14946
+ * The directory is shared with hand-authored hooks and the filenames are fixed
14947
+ * by the contract, so every generated script carries a marker line and a script
14948
+ * without it is never overwritten.
14949
+ *
14950
+ * Cline's CLI and SDK use a different, in-process hook surface (`AgentHooks`
14951
+ * from `@cline/core`), which this adapter does not target.
14952
+ *
14953
+ * @see https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts
14954
+ */
14955
+ var ClineHooks = class ClineHooks extends ToolHooks {
14956
+ scriptsByEvent;
14957
+ constructor(params) {
14958
+ super({
14959
+ ...params,
14960
+ fileContent: params.fileContent ?? ""
14961
+ });
14962
+ this.scriptsByEvent = params.scriptsByEvent ?? {};
14963
+ }
14964
+ static getSettablePaths(options) {
14965
+ return {
14966
+ relativeDirPath: options?.global ? CLINE_HOOKS_GLOBAL_DIR_PATH : CLINE_HOOKS_DIR_PATH,
14967
+ relativeFilePath: CLINE_HOOKS_MANIFEST_FILE_NAME
14968
+ };
14969
+ }
14970
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
14971
+ const paths = ClineHooks.getSettablePaths({ global });
14972
+ const fileContent = await readFileContent(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath));
14973
+ return new ClineHooks({
14974
+ outputRoot,
14975
+ ...paths,
14976
+ fileContent,
14977
+ validate
14978
+ });
14979
+ }
14980
+ static fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
14981
+ const config = rulesyncHooks.getJson();
14982
+ const overrideHooks = config.cline?.hooks ?? {};
14983
+ const scriptsByEvent = collectClineHookCommands({
14984
+ effectiveHooks: {
14985
+ ...config.hooks,
14986
+ ...overrideHooks
14987
+ },
14988
+ eventMap: CANONICAL_TO_CLINE_EVENT_NAMES
14989
+ });
14990
+ const manifest = {
14991
+ generatedBy: "rulesync",
14992
+ events: Object.keys(scriptsByEvent).toSorted()
14993
+ };
14994
+ return new ClineHooks({
14995
+ outputRoot,
14996
+ ...ClineHooks.getSettablePaths({ global }),
14997
+ fileContent: `${JSON.stringify(manifest, null, 2)}\n`,
14998
+ validate,
14999
+ scriptsByEvent
15000
+ });
15001
+ }
15002
+ /**
15003
+ * The per-event scripts, plus a neutralized script for every event a previous
15004
+ * run generated and this one no longer covers — those files stay on disk (the
15005
+ * hooks feature only reconciles its single settable path), so they are
15006
+ * rewritten as no-ops instead of being left running a removed hook.
15007
+ */
15008
+ async getScriptFiles({ global = false, logger } = {}) {
15009
+ const paths = ClineHooks.getSettablePaths({ global });
15010
+ const previous = parseManifest(await readFileContentOrNull(join(this.outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "")?.events ?? [];
15011
+ const events = [.../* @__PURE__ */ new Set([...Object.keys(this.scriptsByEvent), ...previous])].toSorted();
15012
+ const files = [];
15013
+ for (const event of events) {
15014
+ const commands = this.scriptsByEvent[event] ?? [];
15015
+ for (const [relativeFilePath, fileContent] of [[event, generateClineHookScript({
15016
+ event,
15017
+ commands
15018
+ })], [`${event}.ps1`, generateClineHookPowerShellScript({
15019
+ event,
15020
+ commands
15021
+ })]]) {
15022
+ const existing = await readFileContentOrNull(join(this.outputRoot, paths.relativeDirPath, relativeFilePath));
15023
+ if (existing !== null && !existing.includes("rulesync-owned: cline-hooks")) {
15024
+ logger?.warn(`Kept the existing ${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.`);
15025
+ continue;
15026
+ }
15027
+ files.push(new ClineHookScript({
15028
+ outputRoot: this.outputRoot,
15029
+ relativeDirPath: paths.relativeDirPath,
15030
+ relativeFilePath,
15031
+ fileContent
15032
+ }));
15033
+ }
15034
+ }
15035
+ return files;
15036
+ }
15037
+ /**
15038
+ * The generated scripts ride alongside the manifest. Only a `ClineHooks`
15039
+ * instance can produce them, so the processor hands its freshly built one
15040
+ * back here.
15041
+ */
15042
+ static async getAuxiliaryFiles({ global = false, toolHooks, logger } = {}) {
15043
+ if (!(toolHooks instanceof ClineHooks)) return [];
15044
+ return toolHooks.getScriptFiles({
15045
+ global,
15046
+ logger
15047
+ });
15048
+ }
15049
+ /**
15050
+ * Every rulesync-marked script currently on disk. Dropping the target must
15051
+ * take the scripts with it: they hold the actual commands, and leaving them
15052
+ * behind keeps a removed hook running.
15053
+ */
15054
+ static async getDeletableAuxiliaryFiles({ outputRoot = process.cwd(), global = false } = {}) {
15055
+ const paths = ClineHooks.getSettablePaths({ global });
15056
+ const files = [];
15057
+ for (const event of [...MANAGED_EVENT_NAMES].toSorted()) for (const relativeFilePath of [event, `${event}.ps1`]) {
15058
+ const existing = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, relativeFilePath));
15059
+ if (existing === null || !existing.includes("rulesync-owned: cline-hooks")) continue;
15060
+ files.push(new ClineHookScript({
15061
+ outputRoot,
15062
+ relativeDirPath: paths.relativeDirPath,
15063
+ relativeFilePath,
15064
+ fileContent: "",
15065
+ validate: false
15066
+ }));
15067
+ }
15068
+ return files;
15069
+ }
15070
+ toRulesyncHooks() {
15071
+ throw new Error("Not implemented because generated Cline hook scripts cannot be imported back into canonical hooks.");
15072
+ }
15073
+ validate() {
15074
+ return {
15075
+ success: true,
15076
+ error: null
15077
+ };
15078
+ }
15079
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
15080
+ return new ClineHooks({
15081
+ outputRoot,
15082
+ relativeDirPath,
15083
+ relativeFilePath,
15084
+ fileContent: "",
15085
+ validate: false
15086
+ });
15087
+ }
15088
+ };
15089
+ //#endregion
14519
15090
  //#region src/features/hooks/codexcli-hooks.ts
14520
15091
  const CODEXCLI_CONVERTER_CONFIG = {
14521
15092
  supportedEvents: CODEXCLI_HOOK_EVENTS,
@@ -14682,12 +15253,19 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
14682
15253
  /**
14683
15254
  * Copilot hook entry as stored in .github/hooks/copilot-hooks.json.
14684
15255
  *
14685
- * On Windows, commands are emitted under `powershell`; on other platforms, under `bash`.
15256
+ * The canonical `shell` selector chooses `bash` or `powershell`; without it the
15257
+ * portable `command` field is written, which upstream copies to both when
15258
+ * neither is present. Note the cloud agent runs hooks in a Linux sandbox and
15259
+ * honors only `bash` and `command` — a `powershell` entry is ignored there.
15260
+ *
15261
+ * @see https://docs.github.com/en/copilot/reference/hooks-reference
14686
15262
  */
14687
15263
  const CopilotHookEntrySchema = z.looseObject({
14688
15264
  type: z.string(),
14689
15265
  bash: z.optional(z.string()),
14690
15266
  powershell: z.optional(z.string()),
15267
+ command: z.optional(z.string()),
15268
+ env: z.optional(z.record(z.string(), z.string())),
14691
15269
  timeoutSec: z.optional(z.number())
14692
15270
  });
14693
15271
  /**
@@ -14695,12 +15273,16 @@ const CopilotHookEntrySchema = z.looseObject({
14695
15273
  * Filters shared hooks to COPILOT_HOOK_EVENTS, merges config.copilot?.hooks,
14696
15274
  * then converts to Copilot event names and field format.
14697
15275
  *
14698
- * On Windows the command is emitted under the `powershell` key;
14699
- * on all other platforms it is emitted under `bash`.
15276
+ * The command field is chosen by the canonical `shell` selector, falling back to
15277
+ * the portable `command` field never by the platform Rulesync happens to run
15278
+ * on. Keying it off `process.platform` meant a file generated on Windows carried
15279
+ * only `powershell`, which the Linux-sandboxed cloud agent ignores outright, so
15280
+ * the hook silently never ran; it also made the output differ per generating
15281
+ * machine, which shows up as churn for anyone who checks the file in (the cloud
15282
+ * agent reads it from the repository).
14700
15283
  */
14701
15284
  function canonicalToCopilotHooks(config) {
14702
15285
  const canonicalSchemaKeys = Object.keys(HookDefinitionSchema.shape);
14703
- const commandField = process.platform === "win32" ? "powershell" : "bash";
14704
15286
  const supported = new Set(COPILOT_HOOK_EVENTS);
14705
15287
  const sharedConfigHooks = {};
14706
15288
  for (const [event, defs] of Object.entries(config.hooks)) if (supported.has(event)) sharedConfigHooks[event] = defs;
@@ -14718,10 +15300,12 @@ function canonicalToCopilotHooks(config) {
14718
15300
  if (hookType !== "command") continue;
14719
15301
  const command = def.command;
14720
15302
  const timeout = def.timeout;
15303
+ const commandField = def.shell ?? "command";
14721
15304
  const rest = Object.fromEntries(Object.entries(def).filter(([k]) => !canonicalSchemaKeys.includes(k)));
14722
15305
  entries.push({
14723
15306
  type: hookType,
14724
15307
  ...command !== void 0 && command !== null && { [commandField]: command },
15308
+ ...def.env !== void 0 && { env: def.env },
14725
15309
  ...timeout !== void 0 && timeout !== null && { timeoutSec: timeout },
14726
15310
  ...rest
14727
15311
  });
@@ -14731,24 +15315,35 @@ function canonicalToCopilotHooks(config) {
14731
15315
  return copilot;
14732
15316
  }
14733
15317
  /**
14734
- * Resolve the command string from a Copilot hook entry.
15318
+ * Resolve the command and its shell selector from a Copilot hook entry.
14735
15319
  *
14736
- * - If only `bash` is present, use it.
14737
- * - If only `powershell` is present, use it.
14738
- * - If both are present, use `powershell` on Windows, `bash` otherwise,
14739
- * and log a warning that the other value was ignored.
15320
+ * - If only one shell-specific field is present, use it and record the shell,
15321
+ * so a re-export writes the same field back.
15322
+ * - If both are present, take `bash` and warn. The choice is deliberately not
15323
+ * platform-dependent: the cloud agent runs hooks in a Linux sandbox and
15324
+ * ignores `powershell` entirely, and importing on Windows must not produce a
15325
+ * different canonical config than importing the same file on Linux.
15326
+ * - Otherwise fall back to the portable `command` field, leaving `shell` unset
15327
+ * so a re-export renders the portable field again.
14740
15328
  */
14741
15329
  function resolveImportCommand$1(entry, logger) {
14742
15330
  const hasBash = typeof entry.bash === "string";
14743
15331
  const hasPowershell = typeof entry.powershell === "string";
14744
15332
  if (hasBash && hasPowershell) {
14745
- const isWindows = process.platform === "win32";
14746
- const chosen = isWindows ? "powershell" : "bash";
14747
- const ignored = isWindows ? "bash" : "powershell";
14748
- logger?.warn(`Copilot hook has both bash and powershell commands; using ${chosen} and ignoring ${ignored} on this platform.`);
14749
- return isWindows ? entry.powershell : entry.bash;
14750
- } else if (hasBash) return entry.bash;
14751
- else if (hasPowershell) return entry.powershell;
15333
+ logger?.warn("Copilot hook has both bash and powershell commands; using bash and ignoring powershell, which the Linux-sandboxed cloud agent does not run.");
15334
+ return {
15335
+ command: entry.bash,
15336
+ shell: "bash"
15337
+ };
15338
+ } else if (hasBash) return {
15339
+ command: entry.bash,
15340
+ shell: "bash"
15341
+ };
15342
+ else if (hasPowershell) return {
15343
+ command: entry.powershell,
15344
+ shell: "powershell"
15345
+ };
15346
+ return typeof entry.command === "string" ? { command: entry.command } : {};
14752
15347
  }
14753
15348
  /**
14754
15349
  * Extract hooks from Copilot hooks JSON into canonical format.
@@ -14765,11 +15360,13 @@ function copilotHooksToCanonical(copilotHooks, logger) {
14765
15360
  const parseResult = CopilotHookEntrySchema.safeParse(rawEntry);
14766
15361
  if (!parseResult.success) continue;
14767
15362
  const entry = parseResult.data;
14768
- const command = resolveImportCommand$1(entry, logger);
15363
+ const { command, shell } = resolveImportCommand$1(entry, logger);
14769
15364
  const timeout = entry.timeoutSec;
14770
15365
  defs.push({
14771
15366
  type: "command",
14772
15367
  ...command !== void 0 && { command },
15368
+ ...shell !== void 0 && { shell },
15369
+ ...entry.env !== void 0 && { env: entry.env },
14773
15370
  ...timeout !== void 0 && { timeout }
14774
15371
  });
14775
15372
  }
@@ -15928,6 +16525,27 @@ const HERMESAGENT_MATCHER_EVENTS = /* @__PURE__ */ new Set(["pre_tool_call", "po
15928
16525
  const HERMESAGENT_CANONICAL_EVENTS = new Set(HERMESAGENT_HOOK_EVENTS);
15929
16526
  const HERMESAGENT_NATIVE_EVENTS = new Set(HERMESAGENT_NATIVE_HOOK_EVENTS);
15930
16527
  /**
16528
+ * Whether an entry of the `hooks:` mapping is a hook-event list rather than one
16529
+ * of its non-event siblings.
16530
+ *
16531
+ * The mapping is not all events: Hermes v0.20.0 nests the outbound webhook
16532
+ * registry there as `hooks.outbound`, a list of targets (`name`, `url`,
16533
+ * `events`, `secret_env`, `matcher`, `timeout`) that rulesync neither authors
16534
+ * nor imports. A documented native event is an event whatever its value; for
16535
+ * anything else the value decides, because rulesync also emits *undocumented*
16536
+ * event names supplied through the `hermesagent.hooks` override (forward
16537
+ * compatibility), and those must stay retractable. Everything rulesync writes
16538
+ * is a non-empty list of entries carrying a string `command`, which no registry
16539
+ * entry has — `outbound` entries carry `url`/`events` instead.
16540
+ *
16541
+ * Both directions ask this one question, so import and generate cannot drift.
16542
+ * @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
16543
+ */
16544
+ function isHermesHookEventEntry(key, value) {
16545
+ if (HERMESAGENT_NATIVE_EVENTS.has(key)) return true;
16546
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => isPlainObject$1(entry) && typeof entry.command === "string");
16547
+ }
16548
+ /**
15931
16549
  * Convert the canonical hooks config into Hermes's native
15932
16550
  * `hooks: { <event>: [{ matcher?, command, timeout? }] }` shape.
15933
16551
  *
@@ -15988,7 +16606,7 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
15988
16606
  }
15989
16607
  for (const [nativeEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
15990
16608
  if (HERMESAGENT_CANONICAL_EVENTS.has(nativeEvent)) continue;
15991
- 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.`);
16609
+ 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.`);
15992
16610
  setHermesHookEntries({
15993
16611
  result,
15994
16612
  event: nativeEvent,
@@ -16010,6 +16628,7 @@ function hermesHooksToCanonical(hooks) {
16010
16628
  if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return canonical;
16011
16629
  for (const [nativeEvent, entries] of Object.entries(hooks)) {
16012
16630
  if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) continue;
16631
+ if (!isHermesHookEventEntry(nativeEvent, entries)) continue;
16013
16632
  const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;
16014
16633
  const defs = [];
16015
16634
  for (const raw of entries) {
@@ -16029,6 +16648,32 @@ function hermesHooksToCanonical(hooks) {
16029
16648
  return canonical;
16030
16649
  }
16031
16650
  /**
16651
+ * Recompute the `hooks:` mapping that is written back to `config.yaml`.
16652
+ *
16653
+ * rulesync owns the hook events inside that mapping, but not the mapping
16654
+ * itself: Hermes v0.20.0 nests the outbound webhook registry under the same key
16655
+ * as `hooks.outbound`, and it is a list of webhook targets rather than a hook
16656
+ * event, so it has no rulesync spelling and no migration path. Replacing the
16657
+ * whole mapping destroyed it on every generate. Every key that is not an event
16658
+ * ({@link isHermesHookEventEntry}) is therefore carried over from the existing
16659
+ * file, while event keys are replaced wholesale so a hook deleted from the
16660
+ * rulesync source is retracted — including one written under an undocumented
16661
+ * event name through the `hermesagent.hooks` override.
16662
+ * @see https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks
16663
+ */
16664
+ function mergeHermesHooksBlock({ existingHooks, generatedHooks }) {
16665
+ const preserved = {};
16666
+ if (isPlainObject$1(existingHooks)) for (const [key, value] of Object.entries(existingHooks)) {
16667
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
16668
+ if (isHermesHookEventEntry(key, value)) continue;
16669
+ preserved[key] = value;
16670
+ }
16671
+ return {
16672
+ ...preserved,
16673
+ ...isPlainObject$1(generatedHooks) ? generatedHooks : {}
16674
+ };
16675
+ }
16676
+ /**
16032
16677
  * Hermes Agent shell hooks.
16033
16678
  *
16034
16679
  * Hermes Agent registers shell-command hooks under the `hooks:` key of the
@@ -16096,14 +16741,22 @@ var HermesagentHooks = class HermesagentHooks extends ToolHooks {
16096
16741
  return true;
16097
16742
  }
16098
16743
  setFileContent(fileContent) {
16744
+ const existing = parseSharedConfig({
16745
+ format: "yaml",
16746
+ fileContent
16747
+ });
16748
+ const generated = parseSharedConfig({
16749
+ format: "yaml",
16750
+ fileContent: this.fileContent
16751
+ });
16099
16752
  this.fileContent = applySharedConfigPatch({
16100
16753
  fileKey: getHermesagentConfigSharedFileKey({ global: this.global }),
16101
16754
  feature: "hooks",
16102
16755
  existingContent: fileContent,
16103
- patch: parseSharedConfig({
16104
- format: "yaml",
16105
- fileContent: this.fileContent
16106
- })
16756
+ patch: { hooks: mergeHermesHooksBlock({
16757
+ existingHooks: existing.hooks,
16758
+ generatedHooks: generated.hooks
16759
+ }) }
16107
16760
  });
16108
16761
  }
16109
16762
  toRulesyncHooks() {
@@ -16257,15 +16910,36 @@ var JunieHooks = class JunieHooks extends ToolHooks {
16257
16910
  *
16258
16911
  * `experimental.session.compacting` receives `(input, output)` and exposes no
16259
16912
  * per-invocation identifier worth matching on, so it takes `null`.
16913
+ * `chat.message` receives `(input, output)` with the prompt text living in
16914
+ * `output.parts` rather than a single matchable field, so it takes `null` too.
16260
16915
  *
16261
16916
  * @see https://opencode.ai/docs/plugins/
16262
16917
  */
16263
16918
  const NAMED_HOOK_MATCHER_SUBJECTS = {
16264
16919
  "tool.execute.before": "input.tool",
16265
16920
  "tool.execute.after": "input.tool",
16266
- "experimental.session.compacting": null
16921
+ "experimental.session.compacting": null,
16922
+ "chat.message": null
16267
16923
  };
16268
16924
  /**
16925
+ * Canonical events whose generic (`event.type`) dispatch fires more broadly
16926
+ * than the canonical event means, mapped to the extra condition the generated
16927
+ * handler gates on. Keyed by canonical event like `SHELL_EVENT_TOOL_GATES`, so
16928
+ * a second canonical event mapped onto the same dispatch does not inherit a
16929
+ * gate meant for its sibling.
16930
+ *
16931
+ * `permission.replied` fires for every reply — `once`, `always` and `reject` —
16932
+ * so the canonical `permissionDenied` handler runs only for a rejecting reply.
16933
+ *
16934
+ * Note the v1 SDK's generated `Event` typing still describes this payload as
16935
+ * `{ permissionID, response }`; the schema source, the v2 typings and the TUI's
16936
+ * live consumer all agree on `{ requestID, reply }`, so the stale codegen is
16937
+ * not followed here.
16938
+ *
16939
+ * @see https://opencode.ai/docs/plugins/
16940
+ */
16941
+ const GENERIC_EVENT_PROPERTY_GATES = { permissionDenied: "event.properties.reply === \"reject\"" };
16942
+ /**
16269
16943
  * OpenCode (and Kilo) have no shell-execution lifecycle event — the
16270
16944
  * `command.executed` event these canonical events were once mapped to is a
16271
16945
  * *slash-command* event, so a hook wired there never fired on bash commands
@@ -16310,6 +16984,7 @@ function validateAndSanitizeMatcher(matcher) {
16310
16984
  function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHandlers, genericEventHandlers }) {
16311
16985
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
16312
16986
  const shellGate = SHELL_EVENT_TOOL_GATES[canonicalEvent];
16987
+ const propertyGate = GENERIC_EVENT_PROPERTY_GATES[canonicalEvent];
16313
16988
  const toolEvent = shellGate?.toolEvent ?? eventMap[canonicalEvent];
16314
16989
  if (!toolEvent) continue;
16315
16990
  const matcherSupported = !shellGate && Object.hasOwn(NAMED_HOOK_MATCHER_SUBJECTS, toolEvent) && NAMED_HOOK_MATCHER_SUBJECTS[toolEvent] !== null;
@@ -16321,7 +16996,8 @@ function collectOpencodeStyleHandlers({ effectiveHooks, eventMap, namedEventHand
16321
16996
  handlers.push({
16322
16997
  command: def.command,
16323
16998
  matcher: def.matcher ? def.matcher : void 0,
16324
- ...shellGate ? { toolGate: shellGate.tool } : {}
16999
+ ...shellGate ? { toolGate: shellGate.tool } : {},
17000
+ ...propertyGate ? { propertyGate } : {}
16325
17001
  });
16326
17002
  }
16327
17003
  if (handlers.length > 0) {
@@ -16343,7 +17019,11 @@ function buildGenericEventBodyLines(genericEventHandlers) {
16343
17019
  isFirst = false;
16344
17020
  for (const handler of handlers) {
16345
17021
  const escapedCommand = escapeForTemplateLiteral(handler.command);
16346
- bodyLines.push(` await $\`${escapedCommand}\`;`);
17022
+ if (handler.propertyGate) {
17023
+ bodyLines.push(` if (${handler.propertyGate}) {`);
17024
+ bodyLines.push(` await $\`${escapedCommand}\`;`);
17025
+ bodyLines.push(" }");
17026
+ } else bodyLines.push(` await $\`${escapedCommand}\`;`);
16347
17027
  }
16348
17028
  bodyLines.push(" }");
16349
17029
  }
@@ -16711,13 +17391,246 @@ var KimiCodeHooks = class KimiCodeHooks extends ToolHooks {
16711
17391
  }
16712
17392
  };
16713
17393
  //#endregion
17394
+ //#region src/features/hooks/kiro-ide-hooks.ts
17395
+ /**
17396
+ * One hook entry inside the Kiro IDE v1 `hooks` array.
17397
+ *
17398
+ * `z.looseObject` keeps unknown fields added by future Kiro IDE versions, so
17399
+ * imports do not drop data they do not yet understand.
17400
+ * @see https://kiro.dev/docs/hooks/types/
17401
+ */
17402
+ const KiroIdeHookActionSchema = z.union([z.looseObject({
17403
+ type: z.literal("command"),
17404
+ command: z.optional(safeString)
17405
+ }), z.looseObject({
17406
+ type: z.literal("agent"),
17407
+ prompt: z.optional(safeString)
17408
+ })]);
17409
+ const KiroIdeHookEntrySchema = z.looseObject({
17410
+ name: z.optional(z.string()),
17411
+ description: z.optional(z.string()),
17412
+ trigger: z.optional(z.string()),
17413
+ matcher: z.optional(z.string()),
17414
+ action: z.optional(KiroIdeHookActionSchema),
17415
+ timeout: z.optional(z.number()),
17416
+ enabled: z.optional(z.boolean())
17417
+ });
17418
+ const KiroIdeHooksFileSchema = z.looseObject({
17419
+ version: z.optional(z.string()),
17420
+ hooks: z.optional(z.array(KiroIdeHookEntrySchema))
17421
+ });
17422
+ /**
17423
+ * Build the Kiro IDE hook entries for a single canonical event's definitions.
17424
+ *
17425
+ * `command`-type definitions become `{ type: "command", command }` actions and
17426
+ * `prompt`-type definitions become `{ type: "agent", prompt }` actions. Other
17427
+ * types are skipped (the {@link import("./hooks-processor.js").HooksProcessor}
17428
+ * already warns about unsupported types).
17429
+ */
17430
+ function buildKiroIdeEntriesForEvent(trigger, definitions) {
17431
+ const entries = [];
17432
+ for (const def of definitions) {
17433
+ const type = def.type ?? "command";
17434
+ let action;
17435
+ if (type === "command") {
17436
+ if (def.command === void 0) continue;
17437
+ action = {
17438
+ type: "command",
17439
+ command: def.command
17440
+ };
17441
+ } else if (type === "prompt") {
17442
+ if (def.prompt === void 0) continue;
17443
+ action = {
17444
+ type: "agent",
17445
+ prompt: def.prompt
17446
+ };
17447
+ } else continue;
17448
+ entries.push({
17449
+ name: def.name ?? trigger,
17450
+ ...def.description !== void 0 && def.description !== null && { description: def.description },
17451
+ trigger,
17452
+ ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
17453
+ action,
17454
+ ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
17455
+ enabled: def.enabled ?? true
17456
+ });
17457
+ }
17458
+ return entries;
17459
+ }
17460
+ function canonicalToKiroIdeHooks(config, overrideKey) {
17461
+ const kiroIdeSupported = new Set(KIRO_IDE_HOOK_EVENTS);
17462
+ const sharedHooks = {};
17463
+ for (const [event, defs] of Object.entries(config.hooks)) if (kiroIdeSupported.has(event)) sharedHooks[event] = defs;
17464
+ const effectiveHooks = {
17465
+ ...sharedHooks,
17466
+ ...config[overrideKey]?.hooks
17467
+ };
17468
+ const entries = [];
17469
+ for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
17470
+ const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? eventName;
17471
+ entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
17472
+ }
17473
+ return entries;
17474
+ }
17475
+ function kiroIdeHooksToCanonical(entries) {
17476
+ const canonical = {};
17477
+ for (const entry of entries) {
17478
+ if (entry.trigger === void 0 || entry.action === void 0) continue;
17479
+ const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
17480
+ if (isPrototypePollutionKey(eventName)) continue;
17481
+ const def = {};
17482
+ if (entry.action.type === "command") {
17483
+ if (!entry.action.command) continue;
17484
+ def.type = "command";
17485
+ def.command = entry.action.command;
17486
+ } else {
17487
+ if (!entry.action.prompt) continue;
17488
+ def.type = "prompt";
17489
+ def.prompt = entry.action.prompt;
17490
+ }
17491
+ if (entry.name !== void 0 && entry.name !== null) def.name = entry.name;
17492
+ if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
17493
+ if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
17494
+ if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17495
+ if (entry.enabled === false) def.enabled = false;
17496
+ (canonical[eventName] ??= []).push(def);
17497
+ }
17498
+ return canonical;
17499
+ }
17500
+ /**
17501
+ * Hooks generator for the standalone Kiro hooks format (`.kiro/hooks/*.json`
17502
+ * v1), used by the **Kiro IDE** and, since Kiro CLI 3.0, by the CLI too.
17503
+ *
17504
+ * Kiro reads structured JSON hooks from `.kiro/hooks/` (workspace) and
17505
+ * `~/.kiro/hooks/` (user). A single file may declare multiple hooks in its
17506
+ * `hooks` array, so rulesync emits every generated hook into one
17507
+ * `rulesync.json` file per scope (`{ "version": "v1", "hooks": [ ... ] }`),
17508
+ * which keeps it within the single-file hooks architecture.
17509
+ *
17510
+ * {@link import("./kiro-cli-hooks.js").KiroCliHooks} subclasses this to write
17511
+ * the same format for the `kiro-cli` target; only the deprecated `kiro` alias
17512
+ * still writes the embedded `.kiro/agents/default.json` agent-config shape,
17513
+ * which Kiro CLI 3.0 no longer reads.
17514
+ *
17515
+ * @see https://kiro.dev/docs/hooks/
17516
+ */
17517
+ var KiroIdeHooks = class extends ToolHooks {
17518
+ constructor(params) {
17519
+ super({
17520
+ ...params,
17521
+ fileContent: params.fileContent ?? JSON.stringify({
17522
+ version: "v1",
17523
+ hooks: []
17524
+ }, null, 2)
17525
+ });
17526
+ }
17527
+ /**
17528
+ * The `HooksConfig` key whose `hooks` block provides tool-specific overrides
17529
+ * for this target. {@link import("./kiro-cli-hooks.js").KiroCliHooks}
17530
+ * overrides this to `kiro-cli`.
17531
+ */
17532
+ static getOverrideKey() {
17533
+ return "kiro-ide";
17534
+ }
17535
+ static getSettablePaths(_options = {}) {
17536
+ return {
17537
+ relativeDirPath: KIRO_IDE_HOOKS_DIR_PATH,
17538
+ relativeFilePath: KIRO_IDE_HOOKS_FILE_NAME
17539
+ };
17540
+ }
17541
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
17542
+ const paths = this.getSettablePaths({ global });
17543
+ const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({
17544
+ version: "v1",
17545
+ hooks: []
17546
+ }, null, 2);
17547
+ return new this({
17548
+ outputRoot,
17549
+ relativeDirPath: paths.relativeDirPath,
17550
+ relativeFilePath: paths.relativeFilePath,
17551
+ fileContent,
17552
+ validate
17553
+ });
17554
+ }
17555
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
17556
+ const paths = this.getSettablePaths({ global });
17557
+ const hooks = canonicalToKiroIdeHooks(rulesyncHooks.getJson(), this.getOverrideKey());
17558
+ const fileContent = JSON.stringify({
17559
+ version: "v1",
17560
+ hooks
17561
+ }, null, 2);
17562
+ return new this({
17563
+ outputRoot,
17564
+ relativeDirPath: paths.relativeDirPath,
17565
+ relativeFilePath: paths.relativeFilePath,
17566
+ fileContent,
17567
+ validate
17568
+ });
17569
+ }
17570
+ toRulesyncHooks() {
17571
+ let parsed;
17572
+ try {
17573
+ parsed = KiroIdeHooksFileSchema.parse(JSON.parse(this.getFileContent()));
17574
+ } catch (error) {
17575
+ throw new Error(`Failed to parse Kiro IDE hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
17576
+ }
17577
+ const hooks = kiroIdeHooksToCanonical(parsed.hooks ?? []);
17578
+ const overrideKey = this.constructor.getOverrideKey();
17579
+ return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
17580
+ hooks,
17581
+ overrideKey
17582
+ }), null, 2) });
17583
+ }
17584
+ validate() {
17585
+ return {
17586
+ success: true,
17587
+ error: null
17588
+ };
17589
+ }
17590
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
17591
+ return new this({
17592
+ outputRoot,
17593
+ relativeDirPath,
17594
+ relativeFilePath,
17595
+ fileContent: JSON.stringify({
17596
+ version: "v1",
17597
+ hooks: []
17598
+ }, null, 2),
17599
+ validate: false
17600
+ });
17601
+ }
17602
+ };
17603
+ //#endregion
17604
+ //#region src/features/hooks/kiro-cli-hooks.ts
17605
+ /**
17606
+ * Hooks generator for the **Kiro CLI**.
17607
+ *
17608
+ * Kiro CLI 3.0 reads the same standalone `.kiro/hooks/*.json` v1 format the
17609
+ * Kiro IDE reads, so this reuses {@link KiroIdeHooks} and only redirects the
17610
+ * tool-specific override key to `kiro-cli` (so `kiro-cli.hooks` overrides in
17611
+ * the rulesync hooks config are honored, rather than `kiro-ide.hooks`).
17612
+ *
17613
+ * The embedded `.kiro/agents/default.json` agent-hook format this target used
17614
+ * to emit is documented as not working in 3.0, so it is left to the deprecated
17615
+ * `kiro` alias ({@link import("./kiro-hooks.js").KiroHooks}).
17616
+ *
17617
+ * @see https://kiro.dev/docs/cli/v3/hooks-migration/
17618
+ * @see https://kiro.dev/docs/hooks/
17619
+ */
17620
+ var KiroCliHooks = class extends KiroIdeHooks {
17621
+ static getOverrideKey() {
17622
+ return "kiro-cli";
17623
+ }
17624
+ };
17625
+ //#endregion
16714
17626
  //#region src/features/hooks/kiro-hooks.ts
16715
17627
  /**
16716
- * Convert canonical hooks config to Kiro CLI format.
17628
+ * Convert canonical hooks config to the legacy embedded Kiro agent-config
17629
+ * format.
16717
17630
  * Filters shared hooks to KIRO_HOOK_EVENTS, merges config.kiro?.hooks,
16718
- * then maps event names and emits Kiro CLI hook arrays.
17631
+ * then maps event names and emits the agent config's hook arrays.
16719
17632
  */
16720
- /** Build the Kiro CLI hook entries for a single canonical event's definitions. */
17633
+ /** Build the agent-config hook entries for a single canonical event's definitions. */
16721
17634
  function buildKiroEntriesForEvent(definitions) {
16722
17635
  const entries = [];
16723
17636
  for (const def of definitions) {
@@ -16733,7 +17646,8 @@ function buildKiroEntriesForEvent(definitions) {
16733
17646
  }
16734
17647
  return entries;
16735
17648
  }
16736
- function canonicalToKiroHooks(config, overrideKey = "kiro") {
17649
+ function canonicalToKiroHooks(config) {
17650
+ const overrideKey = "kiro";
16737
17651
  const kiroSupported = new Set(KIRO_HOOK_EVENTS);
16738
17652
  const sharedHooks = {};
16739
17653
  for (const [event, defs] of Object.entries(config.hooks)) if (kiroSupported.has(event)) sharedHooks[event] = defs;
@@ -16751,8 +17665,8 @@ function canonicalToKiroHooks(config, overrideKey = "kiro") {
16751
17665
  return kiro;
16752
17666
  }
16753
17667
  /**
16754
- * Kiro CLI hook entry as stored in each event's array.
16755
- * Uses `z.looseObject` so that unknown fields added by future Kiro CLI
17668
+ * Hook entry as stored in each event's array of the agent config.
17669
+ * Uses `z.looseObject` so that unknown fields added by future Kiro
16756
17670
  * versions are accepted and silently ignored during import.
16757
17671
  */
16758
17672
  const KiroHookEntrySchema = z.looseObject({
@@ -16768,7 +17682,7 @@ function importCacheTtl(entry) {
16768
17682
  return { cacheTtl: entry.cache_ttl_seconds };
16769
17683
  }
16770
17684
  /**
16771
- * Extract hooks from Kiro CLI agent config into canonical format.
17685
+ * Extract hooks from the Kiro agent config into canonical format.
16772
17686
  */
16773
17687
  function kiroHooksToCanonical(kiroHooks) {
16774
17688
  if (kiroHooks === null || kiroHooks === void 0 || typeof kiroHooks !== "object") return {};
@@ -16796,6 +17710,17 @@ function kiroHooksToCanonical(kiroHooks) {
16796
17710
  }
16797
17711
  return canonical;
16798
17712
  }
17713
+ /**
17714
+ * Hooks generator for the deprecated `kiro` alias: the embedded hook block of
17715
+ * `.kiro/agents/default.json`.
17716
+ *
17717
+ * Kiro's hooks migration guide states this format "does not work in 3.0", so
17718
+ * the `kiro-cli` target writes the standalone `.kiro/hooks/*.json` v1 format
17719
+ * instead ({@link import("./kiro-cli-hooks.js").KiroCliHooks}). It is kept here
17720
+ * so an existing agent config still round-trips.
17721
+ *
17722
+ * @see https://kiro.dev/docs/cli/v3/hooks-migration/
17723
+ */
16799
17724
  var KiroHooks = class KiroHooks extends ToolHooks {
16800
17725
  constructor(params) {
16801
17726
  super({
@@ -16803,14 +17728,6 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16803
17728
  fileContent: params.fileContent ?? "{}"
16804
17729
  });
16805
17730
  }
16806
- /**
16807
- * The `HooksConfig` key whose `hooks` block provides tool-specific overrides
16808
- * for this target. The legacy `kiro` alias uses `kiro`; {@link import(
16809
- * "./kiro-cli-hooks.js").KiroCliHooks} overrides this to `kiro-cli`.
16810
- */
16811
- static getOverrideKey() {
16812
- return "kiro";
16813
- }
16814
17731
  isDeletable() {
16815
17732
  return false;
16816
17733
  }
@@ -16835,7 +17752,7 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16835
17752
  const paths = KiroHooks.getSettablePaths({ global });
16836
17753
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
16837
17754
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
16838
- const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson(), this.getOverrideKey());
17755
+ const kiroHooks = canonicalToKiroHooks(rulesyncHooks.getJson());
16839
17756
  const fileContent = applySharedConfigPatch({
16840
17757
  fileKey: sharedConfigFileKey(paths),
16841
17758
  feature: "hooks",
@@ -16859,10 +17776,9 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16859
17776
  throw new Error(`Failed to parse Kiro hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
16860
17777
  }
16861
17778
  const hooks = kiroHooksToCanonical(agentConfig.hooks);
16862
- const overrideKey = this.constructor.getOverrideKey();
16863
17779
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
16864
17780
  hooks,
16865
- overrideKey
17781
+ overrideKey: "kiro"
16866
17782
  }), null, 2) });
16867
17783
  }
16868
17784
  validate() {
@@ -16882,222 +17798,6 @@ var KiroHooks = class KiroHooks extends ToolHooks {
16882
17798
  }
16883
17799
  };
16884
17800
  //#endregion
16885
- //#region src/features/hooks/kiro-cli-hooks.ts
16886
- /**
16887
- * Hooks generator for the **Kiro CLI**.
16888
- *
16889
- * The Kiro CLI uses the same `.kiro/agents/default.json` agent-hook format as
16890
- * the legacy `kiro` alias, so this reuses {@link KiroHooks} and only redirects
16891
- * the tool-specific override key to `kiro-cli` (so `kiro-cli.hooks` overrides in
16892
- * the rulesync hooks config are honored, rather than the legacy `kiro.hooks`).
16893
- *
16894
- * (The Kiro IDE uses the structured `.kiro/hooks/*.json` v1 format instead; see
16895
- * {@link import("./kiro-ide-hooks.js").KiroIdeHooks}.)
16896
- */
16897
- var KiroCliHooks = class extends KiroHooks {
16898
- static getOverrideKey() {
16899
- return "kiro-cli";
16900
- }
16901
- };
16902
- //#endregion
16903
- //#region src/features/hooks/kiro-ide-hooks.ts
16904
- /**
16905
- * One hook entry inside the Kiro IDE v1 `hooks` array.
16906
- *
16907
- * `z.looseObject` keeps unknown fields added by future Kiro IDE versions, so
16908
- * imports do not drop data they do not yet understand.
16909
- * @see https://kiro.dev/docs/hooks/types/
16910
- */
16911
- const KiroIdeHookActionSchema = z.union([z.looseObject({
16912
- type: z.literal("command"),
16913
- command: z.optional(safeString)
16914
- }), z.looseObject({
16915
- type: z.literal("agent"),
16916
- prompt: z.optional(safeString)
16917
- })]);
16918
- const KiroIdeHookEntrySchema = z.looseObject({
16919
- name: z.optional(z.string()),
16920
- description: z.optional(z.string()),
16921
- trigger: z.optional(z.string()),
16922
- matcher: z.optional(z.string()),
16923
- action: z.optional(KiroIdeHookActionSchema),
16924
- timeout: z.optional(z.number()),
16925
- enabled: z.optional(z.boolean())
16926
- });
16927
- const KiroIdeHooksFileSchema = z.looseObject({
16928
- version: z.optional(z.string()),
16929
- hooks: z.optional(z.array(KiroIdeHookEntrySchema))
16930
- });
16931
- /**
16932
- * Build the Kiro IDE hook entries for a single canonical event's definitions.
16933
- *
16934
- * `command`-type definitions become `{ type: "command", command }` actions and
16935
- * `prompt`-type definitions become `{ type: "agent", prompt }` actions. Other
16936
- * types are skipped (the {@link import("./hooks-processor.js").HooksProcessor}
16937
- * already warns about unsupported types).
16938
- */
16939
- function buildKiroIdeEntriesForEvent(trigger, definitions) {
16940
- const entries = [];
16941
- for (const def of definitions) {
16942
- const type = def.type ?? "command";
16943
- let action;
16944
- if (type === "command") {
16945
- if (def.command === void 0) continue;
16946
- action = {
16947
- type: "command",
16948
- command: def.command
16949
- };
16950
- } else if (type === "prompt") {
16951
- if (def.prompt === void 0) continue;
16952
- action = {
16953
- type: "agent",
16954
- prompt: def.prompt
16955
- };
16956
- } else continue;
16957
- entries.push({
16958
- name: def.name ?? trigger,
16959
- ...def.description !== void 0 && def.description !== null && { description: def.description },
16960
- trigger,
16961
- ...def.matcher !== void 0 && def.matcher !== null && def.matcher !== "" && { matcher: def.matcher },
16962
- action,
16963
- ...def.timeout !== void 0 && def.timeout !== null && def.timeout >= 0 && { timeout: def.timeout },
16964
- enabled: def.enabled ?? true
16965
- });
16966
- }
16967
- return entries;
16968
- }
16969
- function canonicalToKiroIdeHooks(config) {
16970
- const kiroIdeSupported = new Set(KIRO_IDE_HOOK_EVENTS);
16971
- const sharedHooks = {};
16972
- for (const [event, defs] of Object.entries(config.hooks)) if (kiroIdeSupported.has(event)) sharedHooks[event] = defs;
16973
- const effectiveHooks = {
16974
- ...sharedHooks,
16975
- ...config["kiro-ide"]?.hooks
16976
- };
16977
- const entries = [];
16978
- for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
16979
- const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? eventName;
16980
- entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
16981
- }
16982
- return entries;
16983
- }
16984
- function kiroIdeHooksToCanonical(entries) {
16985
- const canonical = {};
16986
- for (const entry of entries) {
16987
- if (entry.trigger === void 0 || entry.action === void 0) continue;
16988
- const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
16989
- if (isPrototypePollutionKey(eventName)) continue;
16990
- const def = {};
16991
- if (entry.action.type === "command") {
16992
- if (!entry.action.command) continue;
16993
- def.type = "command";
16994
- def.command = entry.action.command;
16995
- } else {
16996
- if (!entry.action.prompt) continue;
16997
- def.type = "prompt";
16998
- def.prompt = entry.action.prompt;
16999
- }
17000
- if (entry.name !== void 0 && entry.name !== null) def.name = entry.name;
17001
- if (entry.description !== void 0 && entry.description !== null) def.description = entry.description;
17002
- if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
17003
- if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
17004
- if (entry.enabled === false) def.enabled = false;
17005
- (canonical[eventName] ??= []).push(def);
17006
- }
17007
- return canonical;
17008
- }
17009
- /**
17010
- * Hooks generator for the **Kiro IDE** (`.kiro/hooks/*.json` v1).
17011
- *
17012
- * Kiro IDE 1.0 reads structured JSON hooks from `.kiro/hooks/` (workspace) and
17013
- * `~/.kiro/hooks/` (user). A single file may declare multiple hooks in its
17014
- * `hooks` array, so rulesync emits every generated hook into one
17015
- * `rulesync.json` file per scope (`{ "version": "v1", "hooks": [ ... ] }`),
17016
- * which keeps it within the single-file hooks architecture.
17017
- *
17018
- * This is distinct from the Kiro CLI ({@link import("./kiro-cli-hooks.js").
17019
- * KiroCliHooks}), which uses the `.kiro/agents/default.json` agent-config shape.
17020
- *
17021
- * @see https://kiro.dev/docs/hooks/
17022
- */
17023
- var KiroIdeHooks = class KiroIdeHooks extends ToolHooks {
17024
- constructor(params) {
17025
- super({
17026
- ...params,
17027
- fileContent: params.fileContent ?? JSON.stringify({
17028
- version: "v1",
17029
- hooks: []
17030
- }, null, 2)
17031
- });
17032
- }
17033
- static getSettablePaths(_options = {}) {
17034
- return {
17035
- relativeDirPath: KIRO_IDE_HOOKS_DIR_PATH,
17036
- relativeFilePath: KIRO_IDE_HOOKS_FILE_NAME
17037
- };
17038
- }
17039
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
17040
- const paths = KiroIdeHooks.getSettablePaths({ global });
17041
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({
17042
- version: "v1",
17043
- hooks: []
17044
- }, null, 2);
17045
- return new KiroIdeHooks({
17046
- outputRoot,
17047
- relativeDirPath: paths.relativeDirPath,
17048
- relativeFilePath: paths.relativeFilePath,
17049
- fileContent,
17050
- validate
17051
- });
17052
- }
17053
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
17054
- const paths = KiroIdeHooks.getSettablePaths({ global });
17055
- const hooks = canonicalToKiroIdeHooks(rulesyncHooks.getJson());
17056
- const fileContent = JSON.stringify({
17057
- version: "v1",
17058
- hooks
17059
- }, null, 2);
17060
- return new KiroIdeHooks({
17061
- outputRoot,
17062
- relativeDirPath: paths.relativeDirPath,
17063
- relativeFilePath: paths.relativeFilePath,
17064
- fileContent,
17065
- validate
17066
- });
17067
- }
17068
- toRulesyncHooks() {
17069
- let parsed;
17070
- try {
17071
- parsed = KiroIdeHooksFileSchema.parse(JSON.parse(this.getFileContent()));
17072
- } catch (error) {
17073
- throw new Error(`Failed to parse Kiro IDE hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
17074
- }
17075
- const hooks = kiroIdeHooksToCanonical(parsed.hooks ?? []);
17076
- return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
17077
- hooks,
17078
- overrideKey: "kiro-ide"
17079
- }), null, 2) });
17080
- }
17081
- validate() {
17082
- return {
17083
- success: true,
17084
- error: null
17085
- };
17086
- }
17087
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
17088
- return new KiroIdeHooks({
17089
- outputRoot,
17090
- relativeDirPath,
17091
- relativeFilePath,
17092
- fileContent: JSON.stringify({
17093
- version: "v1",
17094
- hooks: []
17095
- }, null, 2),
17096
- validate: false
17097
- });
17098
- }
17099
- };
17100
- //#endregion
17101
17801
  //#region src/features/hooks/opencode-hooks.ts
17102
17802
  var OpencodeHooks = class OpencodeHooks extends ToolHooks {
17103
17803
  constructor(params) {
@@ -18161,6 +18861,17 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
18161
18861
  supportedHookTypes: ["command"],
18162
18862
  supportsMatcher: true
18163
18863
  }],
18864
+ ["cline", {
18865
+ class: ClineHooks,
18866
+ meta: {
18867
+ supportsProject: true,
18868
+ supportsGlobal: true,
18869
+ supportsImport: false
18870
+ },
18871
+ supportedEvents: CLINE_HOOK_EVENTS,
18872
+ supportedHookTypes: ["command"],
18873
+ supportsMatcher: false
18874
+ }],
18164
18875
  ["goose", {
18165
18876
  class: GooseHooks,
18166
18877
  meta: {
@@ -18222,12 +18933,13 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
18222
18933
  class: KiroCliHooks,
18223
18934
  meta: {
18224
18935
  supportsProject: true,
18225
- supportsGlobal: false,
18936
+ supportsGlobal: true,
18226
18937
  supportsImport: true
18227
18938
  },
18228
- supportedEvents: KIRO_HOOK_EVENTS,
18229
- supportedHookTypes: ["command"],
18230
- supportsMatcher: true
18939
+ supportedEvents: KIRO_IDE_HOOK_EVENTS,
18940
+ supportedHookTypes: ["command", "prompt"],
18941
+ supportsMatcher: true,
18942
+ passthroughOverrideEvents: true
18231
18943
  }],
18232
18944
  ["kiro-ide", {
18233
18945
  class: KiroIdeHooks,
@@ -18366,7 +19078,11 @@ var HooksProcessor = class extends FeatureProcessor {
18366
19078
  relativeFilePath: paths.relativeFilePath,
18367
19079
  global: this.global
18368
19080
  });
18369
- const list = toolHooks.isDeletable?.() !== false ? [toolHooks] : [];
19081
+ const auxiliaryFiles = await factory.class.getDeletableAuxiliaryFiles?.({
19082
+ outputRoot: this.outputRoot,
19083
+ global: this.global
19084
+ }) ?? [];
19085
+ const list = [...toolHooks.isDeletable?.() !== false ? [toolHooks] : [], ...auxiliaryFiles.filter((file) => file.isDeletable())];
18370
19086
  this.logger.debug(`Successfully loaded ${list.length} ${this.toolTarget} hooks files for deletion`);
18371
19087
  return list;
18372
19088
  }
@@ -18431,7 +19147,7 @@ var HooksProcessor = class extends FeatureProcessor {
18431
19147
  effectiveHooks
18432
19148
  });
18433
19149
  if (eventsWithUnsupportedMatcher.length > 0) this.logger.warn(`Skipped matcher hook(s) for ${this.toolTarget} (not supported): ${eventsWithUnsupportedMatcher.join(", ")}`);
18434
- const result = [await factory.class.fromRulesyncHooks({
19150
+ const toolHooks = await factory.class.fromRulesyncHooks({
18435
19151
  outputRoot: this.outputRoot,
18436
19152
  rulesyncHooks,
18437
19153
  validate: true,
@@ -18440,10 +19156,16 @@ var HooksProcessor = class extends FeatureProcessor {
18440
19156
  logger: this.logger,
18441
19157
  toolTarget: this.toolTarget
18442
19158
  })
18443
- })];
19159
+ });
19160
+ const result = [toolHooks];
18444
19161
  const auxiliaryFiles = await factory.class.getAuxiliaryFiles?.({
18445
19162
  outputRoot: this.outputRoot,
18446
- global: this.global
19163
+ global: this.global,
19164
+ toolHooks,
19165
+ logger: withToolTargetPrefix({
19166
+ logger: this.logger,
19167
+ toolTarget: this.toolTarget
19168
+ })
18447
19169
  });
18448
19170
  if (auxiliaryFiles && auxiliaryFiles.length > 0) result.push(...auxiliaryFiles);
18449
19171
  return result;
@@ -18921,29 +19643,43 @@ var CursorIgnore = class CursorIgnore extends ToolIgnore {
18921
19643
  * `.codeiumignore` filename is read as a fallback so existing projects still
18922
19644
  * round-trip.
18923
19645
  *
19646
+ * In global mode the enterprise-wide `~/.codeium/.codeiumignore` is written
19647
+ * instead; see `DEVIN_GLOBAL_IGNORE_DIR_PATH` for why that path keeps the
19648
+ * legacy brand spelling and sits outside `~/.config/devin`.
19649
+ *
18924
19650
  * @see https://docs.devin.ai/desktop/changelog — v3.1.7 added `.devinignore`
18925
19651
  * alongside `.windsurfignore` and `.codeiumignore`.
18926
19652
  */
18927
19653
  var DevinIgnore = class DevinIgnore extends ToolIgnore {
18928
- static getSettablePaths() {
19654
+ static getSettablePaths({ global = false } = {}) {
18929
19655
  return {
18930
- relativeDirPath: ".",
18931
- relativeFilePath: DEVIN_IGNORE_FILE_NAME
19656
+ relativeDirPath: global ? DEVIN_GLOBAL_IGNORE_DIR_PATH : ".",
19657
+ relativeFilePath: global ? DEVIN_GLOBAL_IGNORE_FILE_NAME : DEVIN_IGNORE_FILE_NAME
18932
19658
  };
18933
19659
  }
18934
19660
  toRulesyncIgnore() {
18935
19661
  return this.toRulesyncIgnoreDefault();
18936
19662
  }
18937
- static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
19663
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, global = false }) {
19664
+ const paths = this.getSettablePaths({ global });
18938
19665
  return new DevinIgnore({
18939
19666
  outputRoot,
18940
- relativeDirPath: this.getSettablePaths().relativeDirPath,
18941
- relativeFilePath: this.getSettablePaths().relativeFilePath,
18942
- fileContent: rulesyncIgnore.getFileContent()
19667
+ relativeDirPath: paths.relativeDirPath,
19668
+ relativeFilePath: paths.relativeFilePath,
19669
+ fileContent: rulesyncIgnore.getFileContent(),
19670
+ global
18943
19671
  });
18944
19672
  }
18945
- static async fromFile({ outputRoot = process.cwd(), validate = true }) {
18946
- const { relativeDirPath, relativeFilePath } = this.getSettablePaths();
19673
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
19674
+ const { relativeDirPath, relativeFilePath } = this.getSettablePaths({ global });
19675
+ if (global) return new DevinIgnore({
19676
+ outputRoot,
19677
+ relativeDirPath,
19678
+ relativeFilePath,
19679
+ fileContent: await readFileContent(join(outputRoot, relativeDirPath, relativeFilePath)),
19680
+ validate,
19681
+ global
19682
+ });
18947
19683
  const primaryPath = join(outputRoot, relativeDirPath, relativeFilePath);
18948
19684
  const legacyPath = join(outputRoot, relativeDirPath, DEVIN_LEGACY_IGNORE_FILE_NAME);
18949
19685
  const resolvedFilePath = !await fileExists(primaryPath) && await fileExists(legacyPath) ? DEVIN_LEGACY_IGNORE_FILE_NAME : relativeFilePath;
@@ -18953,16 +19689,18 @@ var DevinIgnore = class DevinIgnore extends ToolIgnore {
18953
19689
  relativeDirPath,
18954
19690
  relativeFilePath: resolvedFilePath,
18955
19691
  fileContent,
18956
- validate
19692
+ validate,
19693
+ global
18957
19694
  });
18958
19695
  }
18959
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
19696
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
18960
19697
  return new DevinIgnore({
18961
19698
  outputRoot,
18962
19699
  relativeDirPath,
18963
19700
  relativeFilePath,
18964
19701
  fileContent: "",
18965
- validate: false
19702
+ validate: false,
19703
+ global
18966
19704
  });
18967
19705
  }
18968
19706
  };
@@ -19836,6 +20574,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
19836
20574
  ]);
19837
20575
  const ignoreProcessorToolTargets = [...toolIgnoreFactories.keys()];
19838
20576
  const ignoreProcessorGlobalToolTargets = [
20577
+ "devin",
19839
20578
  "kiro",
19840
20579
  "kiro-cli",
19841
20580
  "kiro-ide",
@@ -20763,6 +21502,65 @@ const RULESYNC_TO_CODEX_SCALAR_FIELD_MAP = { experimentalEnvironment: "experimen
20763
21502
  const CODEX_TO_RULESYNC_SCALAR_FIELD_MAP = Object.fromEntries(Object.entries(RULESYNC_TO_CODEX_SCALAR_FIELD_MAP).map(([canonical, codex]) => [codex, canonical]));
20764
21503
  const MAX_REMOVE_EMPTY_ENTRIES_DEPTH$1 = 32;
20765
21504
  /**
21505
+ * Canonical per-server keys Codex has no counterpart for.
21506
+ *
21507
+ * Codex's deserializer (`RawMcpServerConfig`) does not reject unknown keys, so
21508
+ * these are inert rather than fatal — but they are rulesync's own spellings and
21509
+ * only add noise to a hand-edited `config.toml`. `type`/`transport` are safe to
21510
+ * drop because Codex infers the transport from `command` versus `url`.
21511
+ * `tools` is handled separately: it is fatal rather than inert.
21512
+ * @see https://github.com/openai/codex/blob/rust-v0.146.1/codex-rs/config/src/mcp_types.rs
21513
+ */
21514
+ const CODEX_UNSUPPORTED_CANONICAL_KEYS = /* @__PURE__ */ new Set([
21515
+ "type",
21516
+ "transport",
21517
+ "alwaysAllow",
21518
+ "trust",
21519
+ "kiroAutoApprove",
21520
+ "kiroAutoBlock"
21521
+ ]);
21522
+ /**
21523
+ * Canonical millisecond timeouts and the Codex fields they translate to.
21524
+ * Codex takes both as seconds (`f64`), so the value is divided by 1000 and a
21525
+ * fractional result is emitted as-is.
21526
+ * - `timeout` → `tool_timeout_sec`: default timeout for tool calls on the server.
21527
+ * - `networkTimeout` → `startup_timeout_sec`: initialize + list-tools timeout.
21528
+ */
21529
+ const RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP = {
21530
+ timeout: "tool_timeout_sec",
21531
+ networkTimeout: "startup_timeout_sec"
21532
+ };
21533
+ const CODEX_TO_RULESYNC_TIMEOUT_FIELD_MAP = Object.fromEntries(Object.entries(RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP).map(([canonical, codex]) => [codex, canonical]));
21534
+ const MILLISECONDS_PER_SECOND = 1e3;
21535
+ /**
21536
+ * Whether a value is usable as a timeout. Codex builds a `Duration` out of both
21537
+ * timeout fields, and `Duration::try_from_secs_f64` errors on a negative value —
21538
+ * which fails the whole `config.toml`, not just the one server — so a negative
21539
+ * timeout is rejected here rather than written.
21540
+ */
21541
+ function isTimeoutValue(value) {
21542
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
21543
+ }
21544
+ /**
21545
+ * Whether a value is usable as the canonical `headers` map, whose schema is
21546
+ * `record(string, string)`. Checked in both directions so a hand-written
21547
+ * `http_headers` can never be imported into a `.rulesync/mcp.jsonc` that the
21548
+ * next generate would refuse to parse.
21549
+ */
21550
+ function isHeadersRecord(value) {
21551
+ return isPlainObject$1(value) && Object.values(value).every((entry) => typeof entry === "string");
21552
+ }
21553
+ /**
21554
+ * Whether a server config describes a Codex stdio server. Codex branches on
21555
+ * `command` first; a config carrying both `command` and `url` is classified as
21556
+ * stdio here, which matches upstream in the sense that it never reaches the
21557
+ * remote arm — upstream rejects that combination outright ("url is not
21558
+ * supported for stdio").
21559
+ */
21560
+ function isCodexStdioServer(config) {
21561
+ return config["command"] !== void 0;
21562
+ }
21563
+ /**
20766
21564
  * `env_vars` entries are either a bare variable name or `{ name, source }`,
20767
21565
  * where `source = "remote"` reads the variable from the remote executor
20768
21566
  * environment. The other renamed keys (`enabled_tools`, `disabled_tools`) stay
@@ -20820,6 +21618,75 @@ function normalizeCodexMcpServerName(name) {
20820
21618
  usedFallback: true
20821
21619
  };
20822
21620
  }
21621
+ /**
21622
+ * Translate the Codex-native per-server keys that carry a canonical
21623
+ * counterpart under a different name or unit. Returns `undefined` for a key
21624
+ * this translation does not own, leaving it to the caller's other branches.
21625
+ */
21626
+ function translateCodexOnlyKey({ key, value, config, serverName }) {
21627
+ if (key === "tools") return {};
21628
+ if (key === "http_headers") {
21629
+ if ("headers" in config) return {};
21630
+ if (isHeadersRecord(value)) return { entry: ["headers", omitPrototypePollutionKeys(value)] };
21631
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a table of string values`);
21632
+ return {};
21633
+ }
21634
+ const mappedKey = CODEX_TO_RULESYNC_TIMEOUT_FIELD_MAP[key];
21635
+ if (mappedKey) {
21636
+ if (isTimeoutValue(value)) return { entry: [mappedKey, value * MILLISECONDS_PER_SECOND] };
21637
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a non-negative number of seconds`);
21638
+ return {};
21639
+ }
21640
+ if (key === "startup_timeout_ms") {
21641
+ if ("startup_timeout_sec" in config) return {};
21642
+ if (isTimeoutValue(value)) return { entry: ["networkTimeout", value] };
21643
+ warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${serverName}: expected a non-negative number of milliseconds`);
21644
+ return {};
21645
+ }
21646
+ }
21647
+ /**
21648
+ * Translate the canonical per-server keys that Codex spells differently, reads
21649
+ * in another unit, or cannot accept at all. Returns `undefined` for a key this
21650
+ * translation does not own.
21651
+ */
21652
+ function translateCanonicalKeyToCodex({ key, value, isStdio, serverName }) {
21653
+ if (key === "tools") {
21654
+ 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.`);
21655
+ return {};
21656
+ }
21657
+ if (CODEX_UNSUPPORTED_CANONICAL_KEYS.has(key)) return {};
21658
+ if (key === "headers") {
21659
+ if (!isHeadersRecord(value)) {
21660
+ warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key 'headers': expected a table of string values, got ${typeof value}`);
21661
+ return {};
21662
+ }
21663
+ if (isStdio) {
21664
+ warnWithFallback(void 0, `[CodexCliMcp] Dropping 'headers' from stdio MCP server "${serverName}": Codex accepts HTTP headers only on url-based servers.`);
21665
+ return {};
21666
+ }
21667
+ return { entry: ["http_headers", omitPrototypePollutionKeys(value)] };
21668
+ }
21669
+ const mappedKey = RULESYNC_TO_CODEX_TIMEOUT_FIELD_MAP[key];
21670
+ if (mappedKey) {
21671
+ if (isTimeoutValue(value)) return { entry: [mappedKey, value / MILLISECONDS_PER_SECOND] };
21672
+ warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected a non-negative number of milliseconds, got ${typeof value}`);
21673
+ return {};
21674
+ }
21675
+ }
21676
+ /**
21677
+ * Codex states no transport of its own — it infers one from `command` versus
21678
+ * `url`, which is why generate drops the canonical `type`. Restate it for a url
21679
+ * server on the way back, so a config imported from Codex reaches the adapters
21680
+ * that branch on `type` as a remote server rather than one with no transport at
21681
+ * all. `streamable_http` is Codex's only remote transport, and canonical spells
21682
+ * that `http`.
21683
+ */
21684
+ function restateCanonicalTransport(converted) {
21685
+ if (converted["type"] !== void 0) return;
21686
+ if (isCodexStdioServer(converted)) return;
21687
+ if (typeof converted["url"] !== "string") return;
21688
+ converted["type"] = "http";
21689
+ }
20823
21690
  function convertFromCodexFormat(codexMcp) {
20824
21691
  const result = {};
20825
21692
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -20827,7 +21694,15 @@ function convertFromCodexFormat(codexMcp) {
20827
21694
  const converted = {};
20828
21695
  for (const [key, value] of Object.entries(config)) {
20829
21696
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20830
- if (key === "enabled") {
21697
+ const codexOnly = translateCodexOnlyKey({
21698
+ key,
21699
+ value,
21700
+ config,
21701
+ serverName: name
21702
+ });
21703
+ if (codexOnly) {
21704
+ if (codexOnly.entry) converted[codexOnly.entry[0]] = codexOnly.entry[1];
21705
+ } else if (key === "enabled") {
20831
21706
  if (value === false) converted["disabled"] = true;
20832
21707
  } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthFromCodex(value);
20833
21708
  else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
@@ -20840,6 +21715,7 @@ function convertFromCodexFormat(codexMcp) {
20840
21715
  else warnWithFallback(void 0, `Ignored malformed value for ${key} in MCP server ${name}: expected a string`);
20841
21716
  } else converted[key] = value;
20842
21717
  }
21718
+ restateCanonicalTransport(converted);
20843
21719
  result[name] = converted;
20844
21720
  }
20845
21721
  return result;
@@ -20852,9 +21728,18 @@ function convertToCodexFormat(mcpServers) {
20852
21728
  const { codexName, usedFallback } = normalizeCodexMcpServerName(name);
20853
21729
  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.`);
20854
21730
  const converted = {};
21731
+ const isStdio = isCodexStdioServer(config);
20855
21732
  for (const [key, value] of Object.entries(config)) {
20856
21733
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
20857
- if (key === "disabled") {
21734
+ const translated = translateCanonicalKeyToCodex({
21735
+ key,
21736
+ value,
21737
+ isStdio,
21738
+ serverName: name
21739
+ });
21740
+ if (translated) {
21741
+ if (translated.entry) converted[translated.entry[0]] = translated.entry[1];
21742
+ } else if (key === "disabled") {
20858
21743
  if (value === true) converted["enabled"] = false;
20859
21744
  } else if (key === "oauth" && isRecord$1(value)) converted[key] = mapOauthToCodex(value);
20860
21745
  else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
@@ -22297,7 +23182,8 @@ function resolveHermesTimeout(config) {
22297
23182
  * Copies the advanced Hermes-recognized per-server fields that have no canonical
22298
23183
  * alias — `auth` (`oauth` for OAuth 2.1/PKCE), mTLS `client_cert` (string PEM
22299
23184
  * path, or `[cert, key]`/`[cert, key, password]` list) and `client_key`,
22300
- * `connect_timeout` (seconds), and `supports_parallel_tool_calls` — verbatim
23185
+ * `connect_timeout` (seconds), `supports_parallel_tool_calls`,
23186
+ * `keepalive_interval`, and `elicitation` — verbatim
22301
23187
  * from `source` to `target`. Field names are identical on both sides (the
22302
23188
  * canonical `McpServerSchema` is a `looseObject`), so this serves export and
22303
23189
  * import alike. See the Hermes mcp-config-reference.
@@ -22358,6 +23244,14 @@ function copyHermesAdvancedFields(source, target) {
22358
23244
  target.sampling = omitPrototypePollutionKeys(structuredClone(source.sampling));
22359
23245
  copied = true;
22360
23246
  }
23247
+ if (typeof source.keepalive_interval === "number") {
23248
+ target.keepalive_interval = source.keepalive_interval;
23249
+ copied = true;
23250
+ }
23251
+ if (isPlainObject$1(source.elicitation)) {
23252
+ target.elicitation = omitPrototypePollutionKeys(structuredClone(source.elicitation));
23253
+ copied = true;
23254
+ }
22361
23255
  return copied;
22362
23256
  }
22363
23257
  /**
@@ -22401,8 +23295,10 @@ function applyHermesToolsBlock(hermesTools, server) {
22401
23295
  * `url`/`headers`, and per-server tool scoping lives under a `tools: { include,
22402
23296
  * exclude }` block (from the canonical `enabledTools`/`disabledTools`). Only
22403
23297
  * fields Hermes understands are emitted, so the shared `config.yaml` is not
22404
- * polluted with canonical-only aliases (`type`, `transport`, `httpUrl`,
22405
- * `networkTimeout`, ...).
23298
+ * polluted with canonical-only aliases (`type`, `httpUrl`, `networkTimeout`,
23299
+ * ...) — with one exception since v0.20.0: a canonical `sse` server is written
23300
+ * as Hermes's own `transport: sse`, without which Hermes would connect to it
23301
+ * over Streamable HTTP.
22406
23302
  */
22407
23303
  function convertServerToHermes(config) {
22408
23304
  const out = {};
@@ -22422,6 +23318,7 @@ function convertServerToHermes(config) {
22422
23318
  } else if (url !== void 0) {
22423
23319
  out.url = url;
22424
23320
  if (isPlainObject$1(config.headers)) out.headers = omitPrototypePollutionKeys(config.headers);
23321
+ if (config.type === "sse" || config.transport === "sse") out.transport = "sse";
22425
23322
  }
22426
23323
  if (config.disabled === true) out.enabled = false;
22427
23324
  const timeout = resolveHermesTimeout(config);
@@ -22469,6 +23366,7 @@ function convertFromHermesFormat(mcpServers) {
22469
23366
  if (isPlainObject$1(config.env)) server.env = omitPrototypePollutionKeys(config.env);
22470
23367
  if (typeof config.url === "string") server.url = config.url;
22471
23368
  if (isPlainObject$1(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
23369
+ if (typeof config.url === "string" && config.transport === "sse") server.type = "sse";
22472
23370
  if (config.enabled === false) server.disabled = true;
22473
23371
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
22474
23372
  if (isRecord$1(config.tools)) applyHermesToolsBlock(config.tools, server);
@@ -24041,6 +24939,7 @@ const REASONIX_PLUGIN_FIELDS = [
24041
24939
  "env",
24042
24940
  "url",
24043
24941
  "headers",
24942
+ "startup_timeout_seconds",
24044
24943
  "call_timeout_seconds",
24045
24944
  "tool_timeout_seconds"
24046
24945
  ];
@@ -24717,20 +25616,6 @@ function deriveTransportAllowlist(servers) {
24717
25616
  return allowlist;
24718
25617
  }
24719
25618
  //#endregion
24720
- //#region src/features/shared/vibe-config-scope.ts
24721
- /**
24722
- * Vibe selects exactly **one** persistence TOML and does not merge scopes: the
24723
- * trusted project `.vibe/config.toml` when one is discovered, otherwise
24724
- * `~/.vibe/config.toml` (single code path since v2.22.0's ConfigOrchestrator
24725
- * migration). A `--global` run that writes the home file is therefore inert in
24726
- * any project that has its own config — worth a heads-up, since the other Vibe
24727
- * surfaces (rules, hooks, agents, skills) genuinely combine scopes.
24728
- */
24729
- async function warnIfGlobalVibeConfigIsShadowed(logger) {
24730
- if (!await fileExists(join(process.cwd(), ".vibe", "config.toml"))) return;
24731
- 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.");
24732
- }
24733
- //#endregion
24734
25619
  //#region src/features/mcp/vibe-mcp.ts
24735
25620
  const VIBE_MCP_SERVER_FIELDS = [
24736
25621
  "transport",
@@ -24799,9 +25684,8 @@ var VibeMcp = class VibeMcp extends ToolMcp {
24799
25684
  global
24800
25685
  });
24801
25686
  }
24802
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger, global = false }) {
25687
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
24803
25688
  const paths = this.getSettablePaths({ global });
24804
- if (global) await warnIfGlobalVibeConfigIsShadowed(logger);
24805
25689
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
24806
25690
  const existingContent = await readFileContentOrNull(filePath) ?? "";
24807
25691
  const existingServers = normalizeMcpServersArray(parseSharedConfig({
@@ -28456,6 +29340,81 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
28456
29340
  else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${JSON.stringify(item)}.`);
28457
29341
  return result;
28458
29342
  }
29343
+ /**
29344
+ * Assemble the Cursor CLI config to write.
29345
+ *
29346
+ * Cursor scopes the file asymmetrically: "Only permissions can be configured at
29347
+ * the project level. All other CLI settings must be set globally." So
29348
+ * `version`, `editor.vimMode`, `approvalMode` and `sandbox` belong to
29349
+ * `~/.cursor/cli-config.json` alone — writing them into `.cursor/cli.json`
29350
+ * produces keys Cursor ignores, which silently strands an authored
29351
+ * `cursor.approvalMode`.
29352
+ *
29353
+ * @see https://cursor.com/docs/cli/reference/configuration
29354
+ */
29355
+ function mergeCursorCliConfig({ settings, mergedPermissions, cursorOverride, global, filePath, logger }) {
29356
+ if (!global) {
29357
+ warnAboutGlobalOnlyOverrideKeys({
29358
+ cursorOverride,
29359
+ filePath,
29360
+ logger
29361
+ });
29362
+ return {
29363
+ ...settings,
29364
+ permissions: mergedPermissions
29365
+ };
29366
+ }
29367
+ const existingEditor = asExistingEditor({
29368
+ settings,
29369
+ filePath,
29370
+ logger
29371
+ });
29372
+ return {
29373
+ ...settings,
29374
+ ...cursorOverride,
29375
+ version: settings.version ?? 1,
29376
+ editor: {
29377
+ ...existingEditor,
29378
+ vimMode: existingEditor.vimMode ?? false
29379
+ },
29380
+ permissions: mergedPermissions
29381
+ };
29382
+ }
29383
+ /**
29384
+ * The existing `editor` object to merge rulesync's managed `vimMode` into.
29385
+ * Only called in global scope: a project config's `editor` is passed through
29386
+ * untouched, so narrowing it there would warn about a value nothing ignores.
29387
+ */
29388
+ function asExistingEditor({ settings, filePath, logger }) {
29389
+ const raw = settings.editor;
29390
+ if (raw === void 0) return {};
29391
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) return raw;
29392
+ logger?.warn(`Cursor CLI config at ${filePath} has a non-object \`editor\` field; ignoring existing editor settings.`);
29393
+ return {};
29394
+ }
29395
+ /**
29396
+ * Override keys that never reach the file from the override in any scope,
29397
+ * because rulesync re-applies its own managed value over them. Naming them in
29398
+ * the project-scope warning would wrongly promise that `--global` makes them
29399
+ * take effect.
29400
+ */
29401
+ const CURSOR_OVERRIDE_KEYS_ALWAYS_CLOBBERED = /* @__PURE__ */ new Set([
29402
+ "version",
29403
+ "editor",
29404
+ "permissions"
29405
+ ]);
29406
+ /**
29407
+ * Name the override keys that would have been written in global scope but are
29408
+ * dropped here, so the user learns the setting did not take effect and how to
29409
+ * make it.
29410
+ */
29411
+ function warnAboutGlobalOnlyOverrideKeys({ cursorOverride, filePath, logger }) {
29412
+ const strandedKeys = Object.keys(cursorOverride).filter((key) => !CURSOR_OVERRIDE_KEYS_ALWAYS_CLOBBERED.has(key)).toSorted();
29413
+ if (strandedKeys.length === 0) return;
29414
+ const names = strandedKeys.map((key) => `\`${key}\``).join(", ");
29415
+ const [wasWere, itThem] = strandedKeys.length === 1 ? ["was", "it"] : ["were", "them"];
29416
+ 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.`);
29417
+ }
28459
29418
  var CursorPermissions = class CursorPermissions extends ToolPermissions {
28460
29419
  constructor(params) {
28461
29420
  super({
@@ -28496,14 +29455,6 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
28496
29455
  const config = rulesyncPermissions.getJson();
28497
29456
  const { allow, deny } = convertRulesyncToCursorPermissions(config, logger);
28498
29457
  const managedTypes = new Set(Object.keys(config.permission).map((category) => toCursorType(category)));
28499
- const existingEditorRaw = settings.editor;
28500
- let existingEditor;
28501
- if (existingEditorRaw === void 0) existingEditor = {};
28502
- else if (existingEditorRaw !== null && typeof existingEditorRaw === "object" && !Array.isArray(existingEditorRaw)) existingEditor = existingEditorRaw;
28503
- else {
28504
- logger?.warn(`Cursor CLI config at ${filePath} has a non-object \`editor\` field; ignoring existing editor settings.`);
28505
- existingEditor = {};
28506
- }
28507
29458
  const existingPermissionsRaw = settings.permissions;
28508
29459
  let existingPermissions;
28509
29460
  if (existingPermissionsRaw === void 0) existingPermissions = {};
@@ -28520,17 +29471,14 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
28520
29471
  mergedPermissions.allow = mergedAllow;
28521
29472
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
28522
29473
  else delete mergedPermissions.deny;
28523
- const cursorOverride = config.cursor;
28524
- const merged = {
28525
- ...settings,
28526
- ...cursorOverride,
28527
- version: settings.version ?? 1,
28528
- editor: {
28529
- ...existingEditor,
28530
- vimMode: existingEditor.vimMode ?? false
28531
- },
28532
- permissions: mergedPermissions
28533
- };
29474
+ const merged = mergeCursorCliConfig({
29475
+ settings,
29476
+ mergedPermissions,
29477
+ cursorOverride: config.cursor ?? {},
29478
+ global,
29479
+ filePath,
29480
+ logger
29481
+ });
28534
29482
  const fileContent = JSON.stringify(merged, null, 2);
28535
29483
  return new CursorPermissions({
28536
29484
  outputRoot,
@@ -29244,14 +30192,16 @@ const CATEGORY_TO_GROK_TOOL = {
29244
30192
  edit: "Edit",
29245
30193
  write: "Edit",
29246
30194
  grep: "Grep",
29247
- webfetch: "WebFetch"
30195
+ webfetch: "WebFetch",
30196
+ websearch: "WebSearch"
29248
30197
  };
29249
30198
  const GROK_TOOL_TO_CATEGORY = {
29250
30199
  Bash: "bash",
29251
30200
  Read: "read",
29252
30201
  Edit: "edit",
29253
30202
  Grep: "grep",
29254
- WebFetch: "webfetch"
30203
+ WebFetch: "webfetch",
30204
+ WebSearch: "websearch"
29255
30205
  };
29256
30206
  const GROK_MCP_TOOL = "MCPTool";
29257
30207
  /**
@@ -29310,18 +30260,18 @@ function parseGrokEntry(entry) {
29310
30260
  *
29311
30261
  * Grok Build CLI ships a Claude-style rule system under `[permission]` in
29312
30262
  * `~/.grok/config.toml`: `allow` / `deny` / `ask` arrays of entries such as
29313
- * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`, and
29314
- * `WebFetch`, evaluated with precedence `deny > ask > allow`
30263
+ * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`,
30264
+ * `WebFetch`, and `WebSearch`, evaluated with precedence `deny > ask > allow`
29315
30265
  * (https://docs.x.ai/build/settings/reference). rulesync's canonical
29316
30266
  * per-category, per-pattern model maps almost 1:1:
29317
30267
  * - Generate: each `permission.<category>.<pattern> = allow|ask|deny` becomes
29318
30268
  * the matching Grok entry and is bucketed into the `[permission]` array for
29319
- * that action. `bash|read|edit|grep|webfetch` map to their Grok tool;
29320
- * `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*` maps to
29321
- * `MCPTool(...)` (a scoped MCP category folds its address into the
30269
+ * that action. `bash|read|edit|grep|webfetch|websearch` map to their Grok
30270
+ * tool; `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*`
30271
+ * maps to `MCPTool(...)` (a scoped MCP category folds its address into the
29322
30272
  * parentheses, so a non-`*` argument pattern on it is not represented).
29323
- * Categories with no Grok tool (`websearch`, `glob`, `notebookedit`,
29324
- * `agent`) are skipped (with a warning when they carry a `deny` rule, to
30273
+ * Categories with no Grok tool (`glob`, `notebookedit`, `agent`) are
30274
+ * skipped (with a warning when they carry a `deny` rule, to
29325
30275
  * surface the gap). When two canonical rules collapse onto the same Grok
29326
30276
  * entry with different actions (e.g. `edit` allow + `write` deny → `Edit`),
29327
30277
  * the strictest wins (`deny > ask > allow`) and a warning is logged, so the
@@ -29348,8 +30298,8 @@ function parseGrokEntry(entry) {
29348
30298
  * tools it models and (in global scope) the `[ui] permission_mode` value, while
29349
30299
  * every other key
29350
30300
  * (e.g. `[mcp_servers]`, `[permission] rules`, `[sandbox]`) and any user-authored
29351
- * entries for tools rulesync cannot model (e.g. `WebSearch`) are preserved. The
29352
- * file is never deleted.
30301
+ * entries for tool prefixes rulesync cannot model (e.g. `any`) are preserved.
30302
+ * The file is never deleted.
29353
30303
  */
29354
30304
  var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
29355
30305
  constructor(params) {
@@ -29456,7 +30406,7 @@ const ACTION_RANK = {
29456
30406
  };
29457
30407
  /**
29458
30408
  * Collect user-authored entries from an existing `[permission]` array whose
29459
- * tool prefix rulesync cannot model (e.g. `WebSearch`, `any`). Such entries are
30409
+ * tool prefix rulesync cannot model (e.g. `any`). Such entries are
29460
30410
  * preserved verbatim so replacing the arrays does not silently drop them
29461
30411
  * (mirrors the Cursor adapter's preservation of unmanaged types).
29462
30412
  */
@@ -31192,7 +32142,12 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
31192
32142
  "disabled",
31193
32143
  "visible"
31194
32144
  ];
31195
- const QWEN_OVERRIDE_SECURITY_KEYS = ["folderTrust"];
32145
+ const QWEN_OVERRIDE_SECURITY_KEYS = [
32146
+ "folderTrust",
32147
+ "allowedHttpHookUrls",
32148
+ "allowPrivateNetworkHooks"
32149
+ ];
32150
+ const QWEN_GLOBAL_ONLY_SECURITY_KEYS = ["allowPrivateNetworkHooks"];
31196
32151
  const QWEN_OVERRIDE_PERMISSIONS_KEYS = ["autoMode"];
31197
32152
  function asPlainRecord(value) {
31198
32153
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -31204,6 +32159,21 @@ function pickQwenOverrideKeys(group, keys) {
31204
32159
  for (const key of keys) if (source[key] !== void 0) picked[key] = source[key];
31205
32160
  return picked;
31206
32161
  }
32162
+ /**
32163
+ * Drop the global-only `security` keys from the override when generating project
32164
+ * settings, warning once per dropped key. Only the override copy is filtered, so
32165
+ * a value the user already wrote into the project file stays untouched.
32166
+ */
32167
+ function scopeOverrideSecurity(overrideSecurity, { global, relativeFilePath, logger }) {
32168
+ const scoped = { ...asPlainRecord(overrideSecurity) };
32169
+ if (global) return scoped;
32170
+ for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
32171
+ if (scoped[key] === void 0) continue;
32172
+ delete scoped[key];
32173
+ 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.`);
32174
+ }
32175
+ return scoped;
32176
+ }
31207
32177
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31208
32178
  constructor(params) {
31209
32179
  super({
@@ -31272,10 +32242,18 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31272
32242
  ...asPlainRecord(settings.tools),
31273
32243
  ...asPlainRecord(override.tools)
31274
32244
  };
31275
- if (override?.security !== void 0) patch.security = {
31276
- ...asPlainRecord(settings.security),
31277
- ...asPlainRecord(override.security)
31278
- };
32245
+ if (override?.security !== void 0) {
32246
+ const scopedSecurity = scopeOverrideSecurity(override.security, {
32247
+ global,
32248
+ relativeFilePath: paths.relativeFilePath,
32249
+ logger
32250
+ });
32251
+ const mergedSecurity = {
32252
+ ...asPlainRecord(settings.security),
32253
+ ...scopedSecurity
32254
+ };
32255
+ if (Object.keys(mergedSecurity).length > 0) patch.security = mergedSecurity;
32256
+ }
31279
32257
  const fileContent = applySharedConfigPatch({
31280
32258
  fileKey: sharedConfigFileKey(paths),
31281
32259
  feature: "permissions",
@@ -31309,6 +32287,10 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
31309
32287
  });
31310
32288
  const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
31311
32289
  const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
32290
+ for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
32291
+ if (overrideSecurity[key] === void 0) continue;
32292
+ 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.`);
32293
+ }
31312
32294
  const overridePermissions = pickQwenOverrideKeys(settings.permissions, QWEN_OVERRIDE_PERMISSIONS_KEYS);
31313
32295
  const qwencodeOverride = {};
31314
32296
  if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
@@ -32484,7 +33466,6 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
32484
33466
  }
32485
33467
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
32486
33468
  const paths = this.getSettablePaths({ global });
32487
- if (global) await warnIfGlobalVibeConfigIsShadowed(logger);
32488
33469
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
32489
33470
  const existingContent = await readFileContentOrNull(filePath) ?? "";
32490
33471
  const config = parseVibeConfig(existingContent);
@@ -32766,7 +33747,9 @@ function warpSettingsDir() {
32766
33747
  * `[agents.execution_profiles.<id>]` collection:
32767
33748
  * - `command_allowlist` — commands that auto-execute.
32768
33749
  * - `command_denylist` — commands that always require permission (the denylist
32769
- * wins over the allowlist).
33750
+ * wins over the allowlist). Writing it at all replaces Warp's built-in
33751
+ * default denylist, so rulesync warns whenever it emits a non-empty one.
33752
+ * https://docs.warp.dev/cli/permissions-and-profiles/
32770
33753
  *
32771
33754
  * The legacy `[agents.profiles]` keys
32772
33755
  * (`agent_mode_command_execution_allowlist` / `denylist`) are consumed only
@@ -32864,6 +33847,7 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
32864
33847
  if (mergedDeny.length > 0) profiles[DENYLIST_KEY] = mergedDeny;
32865
33848
  else delete profiles[DENYLIST_KEY];
32866
33849
  agents.profiles = profiles;
33850
+ 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.`);
32867
33851
  mergeIntoDefaultExecutionProfile({
32868
33852
  agents,
32869
33853
  mergedAllow,
@@ -34795,7 +35779,10 @@ const ClaudecodeSkillFrontmatterSchema = z.looseObject({
34795
35779
  shell: z.optional(z.string()),
34796
35780
  "disable-model-invocation": z.optional(z.boolean()),
34797
35781
  "user-invocable": z.optional(z.boolean()),
34798
- paths: z.optional(z.union([z.string(), z.array(z.string())]))
35782
+ paths: z.optional(z.union([z.string(), z.array(z.string())])),
35783
+ license: z.optional(z.string()),
35784
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
35785
+ metadata: z.optional(z.looseObject({}))
34799
35786
  });
34800
35787
  /**
34801
35788
  * Builds the Claude Code SKILL.md frontmatter from a rulesync skill, carrying
@@ -34822,7 +35809,10 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
34822
35809
  hooks: section.hooks,
34823
35810
  "disable-model-invocation": resolvedDisableModelInvocation,
34824
35811
  "user-invocable": resolvedUserInvocable,
34825
- paths: section.paths
35812
+ paths: section.paths,
35813
+ license: section.license,
35814
+ compatibility: section.compatibility,
35815
+ metadata: section.metadata
34826
35816
  };
34827
35817
  const frontmatter = {
34828
35818
  name: rulesyncFrontmatter.name,
@@ -34833,6 +35823,115 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
34833
35823
  return frontmatter;
34834
35824
  }
34835
35825
  /**
35826
+ * Builds the `claudecode:` section of a rulesync skill from a Claude Code
35827
+ * SKILL.md frontmatter — the inverse of `buildClaudecodeSkillFrontmatter`, and
35828
+ * extracted for the same reason: to keep `toRulesyncSkill` under the
35829
+ * cyclomatic-complexity cap as fields are added. The truthy/defined split
35830
+ * mirrors that function exactly so the conversion stays symmetric.
35831
+ */
35832
+ function buildClaudecodeSkillSection({ frontmatter, resolvedPaths, scheduledTask }) {
35833
+ const fields = [
35834
+ [
35835
+ "when_to_use",
35836
+ frontmatter.when_to_use,
35837
+ "truthy"
35838
+ ],
35839
+ [
35840
+ "allowed-tools",
35841
+ frontmatter["allowed-tools"],
35842
+ "truthy"
35843
+ ],
35844
+ [
35845
+ "disallowed-tools",
35846
+ frontmatter["disallowed-tools"],
35847
+ "truthy"
35848
+ ],
35849
+ [
35850
+ "model",
35851
+ frontmatter.model,
35852
+ "truthy"
35853
+ ],
35854
+ [
35855
+ "effort",
35856
+ frontmatter.effort,
35857
+ "truthy"
35858
+ ],
35859
+ [
35860
+ "argument-hint",
35861
+ frontmatter["argument-hint"],
35862
+ "truthy"
35863
+ ],
35864
+ [
35865
+ "arguments",
35866
+ frontmatter.arguments,
35867
+ "defined"
35868
+ ],
35869
+ [
35870
+ "context",
35871
+ frontmatter.context,
35872
+ "truthy"
35873
+ ],
35874
+ [
35875
+ "agent",
35876
+ frontmatter.agent,
35877
+ "truthy"
35878
+ ],
35879
+ [
35880
+ "background",
35881
+ frontmatter.background,
35882
+ "defined"
35883
+ ],
35884
+ [
35885
+ "hooks",
35886
+ frontmatter.hooks,
35887
+ "defined"
35888
+ ],
35889
+ [
35890
+ "shell",
35891
+ frontmatter.shell,
35892
+ "truthy"
35893
+ ],
35894
+ [
35895
+ "disable-model-invocation",
35896
+ frontmatter["disable-model-invocation"],
35897
+ "defined"
35898
+ ],
35899
+ [
35900
+ "user-invocable",
35901
+ frontmatter["user-invocable"],
35902
+ "defined"
35903
+ ],
35904
+ [
35905
+ "scheduled-task",
35906
+ scheduledTask || void 0,
35907
+ "defined"
35908
+ ],
35909
+ [
35910
+ "paths",
35911
+ resolvedPaths,
35912
+ "defined"
35913
+ ],
35914
+ [
35915
+ "license",
35916
+ frontmatter.license,
35917
+ "defined"
35918
+ ],
35919
+ [
35920
+ "compatibility",
35921
+ frontmatter.compatibility,
35922
+ "defined"
35923
+ ],
35924
+ [
35925
+ "metadata",
35926
+ frontmatter.metadata,
35927
+ "defined"
35928
+ ]
35929
+ ];
35930
+ const section = {};
35931
+ for (const [key, value, presence] of fields) if (presence === "truthy" ? Boolean(value) : value !== void 0) section[key] = value;
35932
+ return section;
35933
+ }
35934
+ /**
34836
35935
  * Escapes the glob metacharacters in a directory path so it matches literally.
34837
35936
  * A real directory name may contain them — `app/[slug]` in a Next.js tree is
34838
35937
  * the common case, and unescaped `[slug]` reads as a bracket expression that
@@ -34953,25 +36052,11 @@ var ClaudecodeSkill = class extends ToolSkill {
34953
36052
  }
34954
36053
  toRulesyncSkill() {
34955
36054
  const frontmatter = this.getFrontmatter();
34956
- const resolvedPaths = frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath);
34957
- const claudecodeSection = {
34958
- ...frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use },
34959
- ...frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] },
34960
- ...frontmatter["disallowed-tools"] && { "disallowed-tools": frontmatter["disallowed-tools"] },
34961
- ...frontmatter.model && { model: frontmatter.model },
34962
- ...frontmatter.effort && { effort: frontmatter.effort },
34963
- ...frontmatter["argument-hint"] && { "argument-hint": frontmatter["argument-hint"] },
34964
- ...frontmatter.arguments !== void 0 && { arguments: frontmatter.arguments },
34965
- ...frontmatter.context && { context: frontmatter.context },
34966
- ...frontmatter.agent && { agent: frontmatter.agent },
34967
- ...frontmatter.background !== void 0 && { background: frontmatter.background },
34968
- ...frontmatter.hooks !== void 0 && { hooks: frontmatter.hooks },
34969
- ...frontmatter.shell && { shell: frontmatter.shell },
34970
- ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
34971
- ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
34972
- ...this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true },
34973
- ...resolvedPaths !== void 0 && { paths: resolvedPaths }
34974
- };
36055
+ const claudecodeSection = buildClaudecodeSkillSection({
36056
+ frontmatter,
36057
+ resolvedPaths: frontmatter.paths !== void 0 ? frontmatter.paths : deriveNestedSkillPaths(this.relativeDirPath),
36058
+ scheduledTask: this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH
36059
+ });
34975
36060
  const rulesyncFrontmatter = {
34976
36061
  name: frontmatter.name,
34977
36062
  description: frontmatter.description,
@@ -47832,19 +48917,21 @@ var OpenCodeRule = class OpenCodeRule extends ToolRule {
47832
48917
  */
47833
48918
  var PiRule = class PiRule extends ToolRule {
47834
48919
  appendSystemPrompt;
47835
- constructor({ fileContent, root, appendSystemPrompt = false, ...rest }) {
48920
+ contextFileOverride;
48921
+ constructor({ fileContent, root, appendSystemPrompt = false, contextFileOverride = false, ...rest }) {
47836
48922
  super({
47837
48923
  ...rest,
47838
48924
  fileContent,
47839
48925
  root: root ?? false
47840
48926
  });
47841
48927
  this.appendSystemPrompt = appendSystemPrompt;
48928
+ this.contextFileOverride = contextFileOverride;
47842
48929
  }
47843
- static getSettablePaths({ global = false, excludeToolDir } = {}) {
48930
+ static getSettablePaths({ global = false, excludeToolDir, contextFile } = {}) {
47844
48931
  return {
47845
48932
  root: {
47846
48933
  relativeDirPath: global ? buildToolPath(".pi", "agent", excludeToolDir) : ".",
47847
- relativeFilePath: PI_RULE_FILE_NAME
48934
+ relativeFilePath: contextFile === "override" ? PI_RULE_OVERRIDE_FILE_NAME : PI_RULE_FILE_NAME
47848
48935
  },
47849
48936
  appendSystemPrompt: {
47850
48937
  relativeDirPath: global ? buildToolPath(".pi", "agent", excludeToolDir) : ".pi",
@@ -47858,7 +48945,22 @@ var PiRule = class PiRule extends ToolRule {
47858
48945
  * `APPEND_SYSTEM.md` is cleaned up once no rule opts in anymore.
47859
48946
  */
47860
48947
  static getExtraFixedFiles({ global = false } = {}) {
47861
- return [this.getSettablePaths({ global }).appendSystemPrompt];
48948
+ return [this.getSettablePaths({ global }).appendSystemPrompt, this.getSettablePaths({
48949
+ global,
48950
+ contextFile: "override"
48951
+ }).root];
48952
+ }
48953
+ /**
48954
+ * The project-root `AGENTS.md` is written by several other targets
48955
+ * (agentsmd, codexcli, warp, devin, ...), and the root-file ownership map that
48956
+ * arbitrates a shared path only applies to `--check`. With
48957
+ * `pi.contextFile: override` Pi stops writing that file, so leaving it on the
48958
+ * orphan list would make every `pi` generate delete another target's freshly
48959
+ * written output. The global `~/.pi/agent/AGENTS.md` is Pi-exclusive and stays
48960
+ * deletable.
48961
+ */
48962
+ isDeletable() {
48963
+ return !(this.getRelativeDirPath() === "." && this.getRelativeFilePath() === "AGENTS.md");
47862
48964
  }
47863
48965
  /**
47864
48966
  * Pi appends `APPEND_SYSTEM.md` to the system prompt itself, so listing it in
@@ -47883,20 +48985,29 @@ var PiRule = class PiRule extends ToolRule {
47883
48985
  appendSystemPrompt: true
47884
48986
  });
47885
48987
  }
47886
- const relativePath = join(root.relativeDirPath, root.relativeFilePath);
48988
+ const isOverride = relativeFilePath === PI_RULE_OVERRIDE_FILE_NAME;
48989
+ const rootPaths = isOverride ? this.getSettablePaths({
48990
+ global,
48991
+ contextFile: "override"
48992
+ }).root : root;
48993
+ const relativePath = join(rootPaths.relativeDirPath, rootPaths.relativeFilePath);
47887
48994
  const fileContent = await readFileContent(join(outputRoot, relativePath));
47888
48995
  return new PiRule({
47889
48996
  outputRoot,
47890
- relativeDirPath: root.relativeDirPath,
47891
- relativeFilePath: root.relativeFilePath,
48997
+ relativeDirPath: rootPaths.relativeDirPath,
48998
+ relativeFilePath: rootPaths.relativeFilePath,
47892
48999
  fileContent,
47893
49000
  validate,
47894
- root: true
49001
+ root: true,
49002
+ contextFileOverride: isOverride
47895
49003
  });
47896
49004
  }
47897
49005
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
47898
- const { root, appendSystemPrompt } = this.getSettablePaths({ global });
47899
49006
  const frontmatter = rulesyncRule.getFrontmatter();
49007
+ const { root, appendSystemPrompt } = this.getSettablePaths({
49008
+ global,
49009
+ contextFile: frontmatter.pi?.contextFile
49010
+ });
47900
49011
  if (!frontmatter.root && frontmatter.pi?.systemPrompt === "append") return new PiRule({
47901
49012
  outputRoot,
47902
49013
  relativeDirPath: appendSystemPrompt.relativeDirPath,
@@ -47913,7 +49024,8 @@ var PiRule = class PiRule extends ToolRule {
47913
49024
  relativeFilePath: root.relativeFilePath,
47914
49025
  fileContent: rulesyncRule.getBody(),
47915
49026
  validate,
47916
- root: isRoot
49027
+ root: isRoot,
49028
+ contextFileOverride: frontmatter.pi?.contextFile === "override"
47917
49029
  });
47918
49030
  }
47919
49031
  toRulesyncRule() {
@@ -47928,6 +49040,18 @@ var PiRule = class PiRule extends ToolRule {
47928
49040
  },
47929
49041
  body: this.getFileContent()
47930
49042
  });
49043
+ if (this.contextFileOverride) return new RulesyncRule({
49044
+ outputRoot: process.cwd(),
49045
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
49046
+ relativeFilePath: RULESYNC_OVERVIEW_FILE_NAME,
49047
+ frontmatter: {
49048
+ root: true,
49049
+ targets: ["pi"],
49050
+ globs: ["**/*"],
49051
+ pi: { contextFile: "override" }
49052
+ },
49053
+ body: this.getFileContent()
49054
+ });
47931
49055
  return this.toRulesyncRuleDefault();
47932
49056
  }
47933
49057
  validate() {
@@ -47947,14 +49071,16 @@ var PiRule = class PiRule extends ToolRule {
47947
49071
  root: false,
47948
49072
  appendSystemPrompt: true
47949
49073
  });
47950
- const isRoot = relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === root.relativeDirPath);
49074
+ const isOverride = relativeFilePath === PI_RULE_OVERRIDE_FILE_NAME;
49075
+ const isRoot = (relativeFilePath === "AGENTS.md" || isOverride) && (relativeDirPath === "." || relativeDirPath === root.relativeDirPath);
47951
49076
  return new PiRule({
47952
49077
  outputRoot,
47953
49078
  relativeDirPath,
47954
49079
  relativeFilePath,
47955
49080
  fileContent: "",
47956
49081
  validate: false,
47957
- root: isRoot
49082
+ root: isRoot,
49083
+ contextFileOverride: isOverride
47958
49084
  });
47959
49085
  }
47960
49086
  static isTargetedByRulesyncRule(rulesyncRule) {
@@ -49427,8 +50553,9 @@ var RulesProcessor = class extends FeatureProcessor {
49427
50553
  }
49428
50554
  async convertRulesyncFilesToToolFiles(rulesyncFiles) {
49429
50555
  const rulesyncRules = rulesyncFiles.filter((file) => file instanceof RulesyncRule);
49430
- const localRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().localRoot);
49431
- const nonLocalRootRules = rulesyncRules.filter((rule) => !rule.getFrontmatter().localRoot);
50556
+ const alignedRules = this.alignPiContextFile(rulesyncRules);
50557
+ const localRootRules = alignedRules.filter((rule) => rule.getFrontmatter().localRoot);
50558
+ const nonLocalRootRules = alignedRules.filter((rule) => !rule.getFrontmatter().localRoot);
49432
50559
  const factory = this.getFactory(this.toolTarget);
49433
50560
  const { meta } = factory;
49434
50561
  const convertedRules = nonLocalRootRules.map((rulesyncRule) => {
@@ -49821,6 +50948,44 @@ As this project's AI coding tool, you must follow the additional conventions bel
49821
50948
  return [...targetedRootRules, ...nonRootRules];
49822
50949
  }
49823
50950
  /**
50951
+ * Pi reads `AGENTS.override.md` *instead of* `AGENTS.md` from a directory, and
50952
+ * non-root Pi rules are folded into whichever file the root emits. A mix of
50953
+ * opted-in and opted-out rules would therefore split the output across both
50954
+ * files and let Pi silently ignore everything in `AGENTS.md`, so the root rule
50955
+ * decides for all of them: its `pi.contextFile` is copied onto the non-root
50956
+ * rules, and the flag set only on a non-root rule is dropped with a warning.
50957
+ */
50958
+ alignPiContextFile(rules) {
50959
+ if (this.toolTarget !== "pi") return rules;
50960
+ const factory = this.getFactory(this.toolTarget);
50961
+ const targeted = rules.filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
50962
+ const rootContextFile = targeted.some((rule) => rule.getFrontmatter().root === true && rule.getFrontmatter().pi?.contextFile === "override") ? "override" : void 0;
50963
+ const mismatched = targeted.filter((rule) => rule.getFrontmatter().pi?.contextFile !== rootContextFile);
50964
+ if (mismatched.length === 0) return rules;
50965
+ 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)}`);
50966
+ const mismatchedSet = new Set(mismatched);
50967
+ return rules.map((rule) => {
50968
+ if (!mismatchedSet.has(rule)) return rule;
50969
+ const frontmatter = rule.getFrontmatter();
50970
+ const { contextFile: _dropped, ...pi } = frontmatter.pi ?? {};
50971
+ const nextPi = {
50972
+ ...pi,
50973
+ ...rootContextFile ? { contextFile: rootContextFile } : {}
50974
+ };
50975
+ return new RulesyncRule({
50976
+ outputRoot: rule.getOutputRoot(),
50977
+ relativeDirPath: rule.getRelativeDirPath(),
50978
+ relativeFilePath: rule.getRelativeFilePath(),
50979
+ frontmatter: {
50980
+ ...frontmatter,
50981
+ ...Object.keys(nextPi).length > 0 ? { pi: nextPi } : { pi: void 0 }
50982
+ },
50983
+ body: rule.getBody(),
50984
+ validate: false
50985
+ });
50986
+ });
50987
+ }
50988
+ /**
49824
50989
  * Implementation of abstract method from FeatureProcessor
49825
50990
  * Load tool-specific rule configurations and parse them into ToolRule instances
49826
50991
  */
@@ -51878,4 +53043,4 @@ async function importChecksCore(params) {
51878
53043
  //#endregion
51879
53044
  export { JsonLogger as $, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as $t, RulesyncRuleFrontmatterSchema as A, ALL_TOOL_TARGETS_WITH_WILDCARD as At, RulesyncCheck as B, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Bt, CODEXCLI_DIR as C, removeTempDirectory as Ct, RulesyncSkill as D, writeFileBuffer as Dt, RulesyncSubagentFrontmatterSchema as E, toPosixPath as Et, getRulesyncSourceCandidates as F, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Ft, SKILL_FILE_NAME as G, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Gt, stringifyFrontmatter as H, RULESYNC_HOOKS_FILE_NAME as Ht, resolveRulesyncSourceWritePath as I, RULESYNC_CHECKS_RELATIVE_DIR_PATH as It, ConfigFileSchema as J, RULESYNC_MCP_LEGACY_FILE_NAME as Jt, ConfigResolver as K, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Kt, parseJsonc as L, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Lt, RulesyncMcp as M, ToolTargetSchema as Mt, RulesyncIgnore as N, MAX_FILE_SIZE as Nt, RulesyncSkillFrontmatterSchema as O, writeFileContent as Ot, RulesyncHooks as P, RULESYNC_AIIGNORE_FILE_NAME as Pt, ConsoleLogger as Q, RULESYNC_PERMISSIONS_FILE_NAME as Qt, RulesyncCommand as R, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Rt, CODEXCLI_BASH_RULES_FILE_NAME as S, removeFileStrict as St, RulesyncSubagent as T, runWithDirectoryRollback as Tt, loadYaml as U, RULESYNC_HOOKS_LEGACY_FILE_NAME as Ut, RulesyncCheckFrontmatterSchema as V, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Vt, SHARED_USER_MANAGED_CONFIG_PATHS as W, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Wt, SourceEntrySchema as X, RULESYNC_MCP_SCHEMA_URL as Xt, GITIGNORE_DESTINATION_KEY as Y, RULESYNC_MCP_RELATIVE_FILE_PATH as Yt, findControlCharacter as Z, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Zt, CLAUDECODE_LOCAL_RULE_FILE_NAME as _, readFileContent as _t, convertFromTool as a, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as an, assertTreeContainsNoSymlinks as at, CLAUDECODE_SKILLS_DIR_PATH as b, removeDirectoryStrict as bt, SubagentsProcessor as c, ALL_FEATURES_WITH_WILDCARD as cn, createTempDirectory as ct, IgnoreProcessor as d, fileExists as dt, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as en, fallbackLogger as et, HooksProcessor as f, findFilesByGlobs as ft, CLAUDECODE_DIR as g, listDirectoryFiles as gt, QWENCODE_LOCAL_RULE_FILE_NAME as h, isSymlink as ht, getProcessorRegistryEntry as i, RULESYNC_SKILLS_RELATIVE_DIR_PATH as in, assertDirectoryIfExists as it, RulesyncPermissions as j, PACKAGING_TOOL_TARGETS as jt, RulesyncRule as k, ALL_TOOL_TARGETS as kt, SkillsProcessor as l, DEPRECATED_FEATURE_REPLACEMENTS as ln, directoryExists as lt, QWENCODE_DIR as m, getHomeDirectory as mt, checkRulesyncDirExists as n, RULESYNC_RELATIVE_DIR_PATH as nn, CLIError as nt, isPackagingToolTarget as o, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as on, assertWritablePathInsideRoot as ot, CommandsProcessor as p, getFileSize as pt, CONFLICTING_TARGET_PAIRS as q, RULESYNC_MCP_FILE_NAME as qt, generate as r, RULESYNC_RULES_RELATIVE_DIR_PATH as rn, ErrorCodes as rt, RulesProcessor as s, ALL_FEATURES as sn, checkPathTraversal as st, importFromTool as t, RULESYNC_PERMISSIONS_SCHEMA_URL as tn, warnOnConflictingFlags as tt, McpProcessor as u, formatError as un, ensureDir as ut, CLAUDECODE_MEMORIES_DIR_NAME as v, readFileContentOrNull as vt, getLocalSkillDirNames as w, resolvePath as wt, ChecksProcessor as x, removeFile as xt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as y, removeDirectory as yt, RulesyncCommandFrontmatterSchema as z, RULESYNC_CONFIG_SCHEMA_URL as zt };
51880
53045
 
51881
- //# sourceMappingURL=import-KXnvmbzr.js.map
53046
+ //# sourceMappingURL=import-BpKoN2US.js.map