rulesync 16.24.1 → 16.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -444,34 +444,34 @@ const isFeatureValueEnabled = (value) => {
444
444
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
445
445
  //#endregion
446
446
  //#region src/constants/rulesync-paths.ts
447
- const { join: join$304 } = node_path.posix;
447
+ const { join: join$305 } = node_path.posix;
448
448
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
449
449
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
450
450
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
451
451
  const RULES_FEATURE_SUBDIR = "rules";
452
- const CURATED_RULES_FEATURE_SUBDIR = join$304(RULES_FEATURE_SUBDIR, ".curated");
452
+ const CURATED_RULES_FEATURE_SUBDIR = join$305(RULES_FEATURE_SUBDIR, ".curated");
453
453
  const COMMANDS_FEATURE_SUBDIR = "commands";
454
454
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
455
455
  const CHECKS_FEATURE_SUBDIR = "checks";
456
456
  const SKILLS_FEATURE_SUBDIR = "skills";
457
- const CURATED_SKILLS_FEATURE_SUBDIR = join$304(SKILLS_FEATURE_SUBDIR, ".curated");
458
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
459
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
460
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
461
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
462
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
463
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
464
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
465
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
466
- join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
467
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
468
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
457
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$305(SKILLS_FEATURE_SUBDIR, ".curated");
458
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
459
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
460
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
461
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
462
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
463
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
464
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
465
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
466
+ join$305(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
467
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
468
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
469
469
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
470
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
470
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
471
471
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
472
472
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
473
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
474
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
473
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
474
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$305(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
475
475
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
476
476
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
477
477
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -738,7 +738,8 @@ const hooksProcessorToolTargetTuple = [
738
738
  "vibe",
739
739
  "qwencode",
740
740
  "reasonix",
741
- "grokcli"
741
+ "grokcli",
742
+ "zcode"
742
743
  ];
743
744
  const permissionsProcessorToolTargetTuple = [
744
745
  "amp",
@@ -2418,6 +2419,7 @@ const ConfigParamsSchema = zod_mini.z.object({
2418
2419
  simulateCommands: (0, zod_mini.optional)(zod_mini.z.boolean()),
2419
2420
  simulateSubagents: (0, zod_mini.optional)(zod_mini.z.boolean()),
2420
2421
  simulateSkills: (0, zod_mini.optional)(zod_mini.z.boolean()),
2422
+ preserveUnownedHooks: (0, zod_mini.optional)(zod_mini.z.boolean()),
2421
2423
  deriveSubprojectPathFromGlobs: (0, zod_mini.optional)(zod_mini.z.boolean()),
2422
2424
  flattenedCommandNaming: (0, zod_mini.optional)(FlattenedCommandNamingSchema),
2423
2425
  /**
@@ -2566,6 +2568,7 @@ var Config = class Config {
2566
2568
  simulateCommands;
2567
2569
  simulateSubagents;
2568
2570
  simulateSkills;
2571
+ preserveUnownedHooks;
2569
2572
  deriveSubprojectPathFromGlobs;
2570
2573
  flattenedCommandNaming;
2571
2574
  language;
@@ -2592,7 +2595,7 @@ var Config = class Config {
2592
2595
  inputRoots;
2593
2596
  configFilePath;
2594
2597
  sources;
2595
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, deriveSubprojectPathFromGlobs, flattenedCommandNaming, language, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2598
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, preserveUnownedHooks, deriveSubprojectPathFromGlobs, flattenedCommandNaming, language, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2596
2599
  assertTargetsFeaturesExclusive({
2597
2600
  targets,
2598
2601
  features
@@ -2624,6 +2627,7 @@ var Config = class Config {
2624
2627
  this.simulateCommands = simulateCommands ?? false;
2625
2628
  this.simulateSubagents = simulateSubagents ?? false;
2626
2629
  this.simulateSkills = simulateSkills ?? false;
2630
+ this.preserveUnownedHooks = preserveUnownedHooks ?? false;
2627
2631
  this.deriveSubprojectPathFromGlobs = deriveSubprojectPathFromGlobs ?? false;
2628
2632
  this.flattenedCommandNaming = flattenedCommandNaming ?? "basename";
2629
2633
  this.language = language;
@@ -2802,6 +2806,9 @@ var Config = class Config {
2802
2806
  getSimulateCommands() {
2803
2807
  return this.simulateCommands;
2804
2808
  }
2809
+ getPreserveUnownedHooks() {
2810
+ return this.preserveUnownedHooks;
2811
+ }
2805
2812
  getFlattenedCommandNaming() {
2806
2813
  return this.flattenedCommandNaming;
2807
2814
  }
@@ -2881,6 +2888,7 @@ const getDefaults = () => ({
2881
2888
  simulateCommands: false,
2882
2889
  simulateSubagents: false,
2883
2890
  simulateSkills: false,
2891
+ preserveUnownedHooks: false,
2884
2892
  deriveSubprojectPathFromGlobs: false,
2885
2893
  flattenedCommandNaming: "basename",
2886
2894
  gitignoreTargetsOnly: true,
@@ -2929,6 +2937,7 @@ const mergeConfigs = (baseConfig, localConfig) => {
2929
2937
  simulateCommands: localConfig.simulateCommands ?? baseConfig.simulateCommands,
2930
2938
  simulateSubagents: localConfig.simulateSubagents ?? baseConfig.simulateSubagents,
2931
2939
  simulateSkills: localConfig.simulateSkills ?? baseConfig.simulateSkills,
2940
+ preserveUnownedHooks: localConfig.preserveUnownedHooks ?? baseConfig.preserveUnownedHooks,
2932
2941
  deriveSubprojectPathFromGlobs: localConfig.deriveSubprojectPathFromGlobs ?? baseConfig.deriveSubprojectPathFromGlobs,
2933
2942
  flattenedCommandNaming: localConfig.flattenedCommandNaming ?? baseConfig.flattenedCommandNaming,
2934
2943
  language: localConfig.language ?? baseConfig.language,
@@ -3152,6 +3161,11 @@ var ConfigResolver = class {
3152
3161
  file: configByFile.simulateSkills,
3153
3162
  fallback: getDefaults().simulateSkills
3154
3163
  }),
3164
+ preserveUnownedHooks: pick({
3165
+ cli: void 0,
3166
+ file: configByFile.preserveUnownedHooks,
3167
+ fallback: getDefaults().preserveUnownedHooks
3168
+ }),
3155
3169
  deriveSubprojectPathFromGlobs: pick({
3156
3170
  cli: deriveSubprojectPathFromGlobs,
3157
3171
  file: configByFile.deriveSubprojectPathFromGlobs,
@@ -4711,6 +4725,43 @@ const KIMI_CODE_NATIVE_HOOK_EVENTS = [
4711
4725
  ];
4712
4726
  const KIMI_CODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_KIMI_CODE_EVENT_NAMES).map(([canonical, kimiCode]) => [kimiCode, canonical]));
4713
4727
  /**
4728
+ * Hook events supported by ZCode.
4729
+ *
4730
+ * ZCode's configuration-file hooks expose exactly seven PascalCase events, all
4731
+ * of which have a clean canonical equivalent: `SessionStart`, `PreToolUse`,
4732
+ * `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Stop`, and
4733
+ * `UserPromptSubmit` ← `beforeSubmitPrompt`. An optional matcher (a
4734
+ * case-sensitive regular expression) is honored on all of them except
4735
+ * `UserPromptSubmit` and `Stop`, which expose no value to match against.
4736
+ * Configuration hooks are read only from the user config
4737
+ * `~/.zcode/cli/config.json` (workspace config hooks are never executed) and
4738
+ * additionally require `hooks.enabled: true` to run.
4739
+ *
4740
+ * ZCode also accepts a native `process` hook type (an argv run without a
4741
+ * shell) which has no canonical equivalent; see ZcodeHooks.
4742
+ *
4743
+ * @see https://zcode.z.ai/en/docs
4744
+ */
4745
+ const ZCODE_HOOK_EVENTS = [
4746
+ "sessionStart",
4747
+ "beforeSubmitPrompt",
4748
+ "preToolUse",
4749
+ "postToolUse",
4750
+ "postToolUseFailure",
4751
+ "permissionRequest",
4752
+ "stop"
4753
+ ];
4754
+ const CANONICAL_TO_ZCODE_EVENT_NAMES = {
4755
+ sessionStart: "SessionStart",
4756
+ beforeSubmitPrompt: "UserPromptSubmit",
4757
+ preToolUse: "PreToolUse",
4758
+ postToolUse: "PostToolUse",
4759
+ postToolUseFailure: "PostToolUseFailure",
4760
+ permissionRequest: "PermissionRequest",
4761
+ stop: "Stop"
4762
+ };
4763
+ const ZCODE_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_ZCODE_EVENT_NAMES).map(([k, v]) => [v, k]));
4764
+ /**
4714
4765
  * Hook events supported by Hermes Agent's native Shell Hooks system.
4715
4766
  *
4716
4767
  * Hermes validates hook events against a fixed `VALID_HOOKS` set — 37 entries as
@@ -4862,6 +4913,7 @@ const HooksConfigSchema = zod_mini.z.looseObject({
4862
4913
  reasonix: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
4863
4914
  grokcli: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
4864
4915
  "kimi-code": zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
4916
+ zcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
4865
4917
  qwencode: zod_mini.z.optional(zod_mini.z.looseObject({
4866
4918
  hooks: zod_mini.z.optional(hooksRecordSchema),
4867
4919
  disableAllHooks: zod_mini.z.optional(zod_mini.z.boolean())
@@ -12412,14 +12464,15 @@ const KIMI_CODE_CONFIG_DECLARATION = {
12412
12464
  };
12413
12465
  /**
12414
12466
  * ZCode's settings file, which also carries model/theme/permission keys
12415
- * rulesync does not own. The workspace copy (`<project>/.zcode/config.json`)
12416
- * and the user copy (`~/.zcode/cli/config.json`) are the same file with the
12417
- * same owners, so the declaration is written once and shared a policy edit
12418
- * cannot land on one scope only. `mcp` is owned as a whole key because the
12419
- * writer recomputes it from the existing file (non-`servers` siblings carried
12420
- * over) before applying the patch.
12421
- */
12422
- const ZCODE_CONFIG_DECLARATION = {
12467
+ * rulesync does not own. Both copies are the user's primary ZCode config, so
12468
+ * every writer refuses to read-modify-write a file it could not parse rather
12469
+ * than replacing it with generated output. `mcp` and `hooks` are owned as
12470
+ * whole keys because their writers recompute each from the existing file
12471
+ * (non-owned siblings carried over) before applying the patch. ZCode never
12472
+ * executes workspace config hooks, so the workspace copy is declared with
12473
+ * `mcp` alone and the user copy adds `hooks`.
12474
+ */
12475
+ const ZCODE_WORKSPACE_CONFIG_DECLARATION = {
12423
12476
  format: "json",
12424
12477
  invalidRootPolicy: "error",
12425
12478
  features: { mcp: {
@@ -12427,6 +12480,16 @@ const ZCODE_CONFIG_DECLARATION = {
12427
12480
  ownedKeys: ["mcp"]
12428
12481
  } }
12429
12482
  };
12483
+ const ZCODE_USER_CONFIG_DECLARATION = {
12484
+ ...ZCODE_WORKSPACE_CONFIG_DECLARATION,
12485
+ features: {
12486
+ ...ZCODE_WORKSPACE_CONFIG_DECLARATION.features,
12487
+ hooks: {
12488
+ kind: "replace-owned-keys",
12489
+ ownedKeys: ["hooks"]
12490
+ }
12491
+ }
12492
+ };
12430
12493
  /**
12431
12494
  * What the two Claude Code settings files have in common: both are plain JSON,
12432
12495
  * and both validate against the one published schema, which the gateway
@@ -12684,8 +12747,8 @@ const SHARED_CONFIG_OWNERSHIP = {
12684
12747
  ownedKeys: ["mcp_servers", "schema_version"]
12685
12748
  } }
12686
12749
  },
12687
- ".zcode/config.json": ZCODE_CONFIG_DECLARATION,
12688
- ".zcode/cli/config.json": ZCODE_CONFIG_DECLARATION,
12750
+ ".zcode/config.json": ZCODE_WORKSPACE_CONFIG_DECLARATION,
12751
+ ".zcode/cli/config.json": ZCODE_USER_CONFIG_DECLARATION,
12689
12752
  ".kiro/agents/default.json": {
12690
12753
  format: "json",
12691
12754
  features: {
@@ -18349,6 +18412,8 @@ const ZCODE_SKILLS_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "skills");
18349
18412
  const ZCODE_CONFIG_FILE_NAME = "config.json";
18350
18413
  const ZCODE_GLOBAL_CONFIG_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "cli");
18351
18414
  const ZCODE_MCP_SERVERS_KEY = "servers";
18415
+ const ZCODE_HOOKS_CONFIG_KEY = "hooks";
18416
+ const ZCODE_HOOKS_EVENTS_KEY = "events";
18352
18417
  const ZCODE_AGENTS_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "agents");
18353
18418
  //#endregion
18354
18419
  //#region src/features/commands/zcode-command.ts
@@ -19221,19 +19286,124 @@ function generateAmpPluginCode({ config, supportedEvents, eventMap }) {
19221
19286
  lines.push("}", "");
19222
19287
  return lines.join("\n");
19223
19288
  }
19289
+ /**
19290
+ * Name of the ownership record written next to the hooks destination it
19291
+ * describes (e.g. `.claude/.rulesync-hooks-lock.json`). Only written when hook
19292
+ * preservation is enabled, so a project that never opts in never sees it.
19293
+ */
19294
+ const HOOKS_OWNERSHIP_LOCK_FILE_NAME = ".rulesync-hooks-lock.json";
19295
+ const OwnedHookRefSchema = zod_mini.z.object({
19296
+ event: zod_mini.z.string(),
19297
+ matcher: zod_mini.z.optional(zod_mini.z.string()),
19298
+ identity: zod_mini.z.string()
19299
+ });
19300
+ const HooksOwnershipLockSchema = zod_mini.z.object({
19301
+ lockfileVersion: zod_mini.z.number(),
19302
+ owned: zod_mini.z.array(OwnedHookRefSchema)
19303
+ });
19304
+ /**
19305
+ * The comparable form of a hook entry: identical keys mean "the same hook, in
19306
+ * the same place". Encoded as JSON so no separator can collide with a matcher
19307
+ * or an identity that happens to contain one.
19308
+ */
19309
+ function ownedHookKey({ event, matcher, identity }) {
19310
+ return JSON.stringify([
19311
+ event,
19312
+ matcher ?? null,
19313
+ identity
19314
+ ]);
19315
+ }
19316
+ /**
19317
+ * Read the previous run's owned set. Anything unreadable — missing, empty,
19318
+ * malformed, or written by a different lock version — yields an empty set,
19319
+ * which makes every existing handler look third-party and therefore preserved.
19320
+ */
19321
+ function parseHooksOwnershipLock(content) {
19322
+ if (content === null || content.trim() === "") return /* @__PURE__ */ new Set();
19323
+ let parsed;
19324
+ try {
19325
+ parsed = JSON.parse(content);
19326
+ } catch {
19327
+ return /* @__PURE__ */ new Set();
19328
+ }
19329
+ const result = HooksOwnershipLockSchema.safeParse(parsed);
19330
+ if (!result.success || result.data.lockfileVersion !== 1) return /* @__PURE__ */ new Set();
19331
+ return new Set(result.data.owned.map((ref) => ownedHookKey(ref)));
19332
+ }
19333
+ function compareOwnedHookRefs(a, b) {
19334
+ const left = ownedHookKey(a);
19335
+ const right = ownedHookKey(b);
19336
+ if (left === right) return 0;
19337
+ return left < right ? -1 : 1;
19338
+ }
19339
+ /** Serialize the owned set in a stable order so regenerating produces no diff. */
19340
+ function serializeHooksOwnershipLock(owned) {
19341
+ return `${JSON.stringify({
19342
+ lockfileVersion: 1,
19343
+ owned: [...owned].toSorted(compareOwnedHookRefs)
19344
+ }, null, 2)}\n`;
19345
+ }
19346
+ /**
19347
+ * The ownership record itself. It is a rulesync by-product rather than tool
19348
+ * configuration, so it is deletable: dropping the target should take it along.
19349
+ */
19350
+ var HooksOwnershipLockFile = class extends ToolFile {
19351
+ isDeletable() {
19352
+ return true;
19353
+ }
19354
+ validate() {
19355
+ return {
19356
+ success: true,
19357
+ error: null
19358
+ };
19359
+ }
19360
+ };
19361
+ /** Build the lock file that records what this run generated. */
19362
+ function buildHooksOwnershipLockFile({ outputRoot, relativeDirPath, owned }) {
19363
+ return new HooksOwnershipLockFile({
19364
+ outputRoot,
19365
+ relativeDirPath,
19366
+ relativeFilePath: HOOKS_OWNERSHIP_LOCK_FILE_NAME,
19367
+ fileContent: serializeHooksOwnershipLock(owned),
19368
+ validate: false
19369
+ });
19370
+ }
19224
19371
  //#endregion
19225
19372
  //#region src/features/hooks/tool-hooks.ts
19226
19373
  var ToolHooks = class extends ToolFile {
19374
+ ownedHookRefs;
19227
19375
  constructor(params) {
19228
19376
  super({
19229
19377
  ...params,
19230
19378
  validate: true
19231
19379
  });
19380
+ this.ownedHookRefs = params.ownedHookRefs;
19232
19381
  if (params.validate) {
19233
19382
  const result = this.validate();
19234
19383
  if (!result.success) throw result.error;
19235
19384
  }
19236
19385
  }
19386
+ /**
19387
+ * The ownership record for this destination, or nothing when preservation is
19388
+ * off — in which case rulesync owns the whole list and needs no record.
19389
+ */
19390
+ getOwnershipLockFiles() {
19391
+ if (this.ownedHookRefs === void 0) return [];
19392
+ return [buildHooksOwnershipLockFile({
19393
+ outputRoot: this.getOutputRoot(),
19394
+ relativeDirPath: this.getRelativeDirPath(),
19395
+ owned: this.ownedHookRefs
19396
+ })];
19397
+ }
19398
+ /**
19399
+ * Whether the adapter can keep handlers it did not generate. Destinations
19400
+ * rulesync owns outright (plugin bundles) must answer `false`: there is no
19401
+ * third party writing into them, and preserving there would only make
19402
+ * removals impossible.
19403
+ */
19404
+ static supportsPreserveUnowned() {
19405
+ return false;
19406
+ }
19237
19407
  static getSettablePaths(_options) {
19238
19408
  throw new Error("Please implement this method in the subclass.");
19239
19409
  }
@@ -20670,6 +20840,292 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20670
20840
  }
20671
20841
  };
20672
20842
  //#endregion
20843
+ //#region src/features/hooks/preserve-unowned-hook-commands.ts
20844
+ /**
20845
+ * Read the `hooks` value from a destination JSON file.
20846
+ *
20847
+ * `malformed` separates "the file has no hooks" from "the file could not be
20848
+ * read", so a caller that is about to preserve content can say out loud that
20849
+ * it is replacing a file it failed to parse.
20850
+ */
20851
+ function parseExistingHooksValue(existingContent) {
20852
+ if (existingContent.trim() === "") return {
20853
+ hooks: void 0,
20854
+ malformed: false
20855
+ };
20856
+ try {
20857
+ const parsed = JSON.parse(existingContent);
20858
+ return {
20859
+ hooks: isPlainObject$1(parsed) ? parsed.hooks : void 0,
20860
+ malformed: false
20861
+ };
20862
+ } catch {
20863
+ return {
20864
+ hooks: void 0,
20865
+ malformed: true
20866
+ };
20867
+ }
20868
+ }
20869
+ /**
20870
+ * Produce the destination hooks list, optionally keeping handlers rulesync does
20871
+ * not own.
20872
+ *
20873
+ * Ownership is decided by `previouslyOwned` — the identities recorded in the
20874
+ * destination's ownership lock on the previous run — and never by guessing from
20875
+ * the command text. That is what makes removal work: a handler rulesync wrote
20876
+ * before and no longer generates is retracted, while a handler rulesync has
20877
+ * never written is left alone. Without a lock (the first run after opting in)
20878
+ * nothing is retracted, which is the non-destructive direction.
20879
+ *
20880
+ * Callers must pass the destination's shape. Plugin destinations are fully
20881
+ * owned by rulesync and must not call this.
20882
+ */
20883
+ function mergeGeneratedHookLists({ existingContent, generatedHooks, shape, preserveUnowned, previouslyOwned, logger }) {
20884
+ const owned = collectOwnedRefs({
20885
+ generatedHooks,
20886
+ shape
20887
+ });
20888
+ if (!preserveUnowned) return {
20889
+ hooks: generatedHooks,
20890
+ owned
20891
+ };
20892
+ const { hooks: existingHooks, malformed } = parseExistingHooksValue(existingContent);
20893
+ if (malformed) logger?.warn("Replacing hooks wholesale: the existing file is not valid JSON, so no third-party hook in it can be preserved.");
20894
+ return {
20895
+ hooks: preserveUnownedHookCommands({
20896
+ existingHooks,
20897
+ generatedHooks,
20898
+ shape,
20899
+ owned,
20900
+ previouslyOwned: previouslyOwned ?? /* @__PURE__ */ new Set(),
20901
+ logger
20902
+ }),
20903
+ owned
20904
+ };
20905
+ }
20906
+ /**
20907
+ * Merge the destination's existing handlers into the generated set.
20908
+ *
20909
+ * Each existing handler falls into exactly one of three cases:
20910
+ * - this run generates it → the generated copy stands, the existing one is dropped;
20911
+ * - a previous run generated it and this one does not → it is retracted, with a warning;
20912
+ * - neither → it is unowned, and kept.
20913
+ */
20914
+ function preserveUnownedHookCommands({ existingHooks, generatedHooks, shape, owned, previouslyOwned, logger }) {
20915
+ const result = cloneGeneratedHooks({
20916
+ generatedHooks,
20917
+ shape
20918
+ });
20919
+ if (!isPlainObject$1(existingHooks)) return result;
20920
+ const seen = new Set(owned.map((ref) => ownedHookKey(ref)));
20921
+ for (const [event, existingValue] of Object.entries(existingHooks)) {
20922
+ if (isPrototypePollutionKey(event)) continue;
20923
+ if (!Array.isArray(existingValue)) {
20924
+ warnSkip({
20925
+ logger,
20926
+ event,
20927
+ expected: "an array of hook entries"
20928
+ });
20929
+ continue;
20930
+ }
20931
+ const merged = shape === "matcher-groups" ? mergeMatcherGroups({
20932
+ existing: existingValue,
20933
+ generated: result[event] ?? [],
20934
+ event,
20935
+ seen,
20936
+ previouslyOwned,
20937
+ logger
20938
+ }) : mergeFlatHandlers({
20939
+ existing: existingValue,
20940
+ generated: result[event] ?? [],
20941
+ event,
20942
+ seen,
20943
+ previouslyOwned,
20944
+ logger
20945
+ });
20946
+ if (merged.length === 0) delete result[event];
20947
+ else result[event] = merged;
20948
+ }
20949
+ return result;
20950
+ }
20951
+ function cloneGeneratedHooks({ generatedHooks, shape }) {
20952
+ const result = Object.create(null);
20953
+ for (const [event, value] of Object.entries(generatedHooks)) {
20954
+ if (isPrototypePollutionKey(event) || !Array.isArray(value)) continue;
20955
+ result[event] = shape === "matcher-groups" ? value.map((group) => cloneMatcherGroup(group)) : value.map((handler) => cloneHandler(handler));
20956
+ }
20957
+ return result;
20958
+ }
20959
+ /** The identities of every handler in the generated set, in destination order. */
20960
+ function collectOwnedRefs({ generatedHooks, shape }) {
20961
+ const owned = [];
20962
+ for (const [event, value] of Object.entries(generatedHooks)) {
20963
+ if (isPrototypePollutionKey(event) || !Array.isArray(value)) continue;
20964
+ if (shape === "flat") {
20965
+ for (const handler of value) if (isPlainObject$1(handler)) owned.push({
20966
+ event,
20967
+ identity: handlerIdentity(handler)
20968
+ });
20969
+ continue;
20970
+ }
20971
+ for (const group of value) {
20972
+ if (!isMatcherGroup(group)) continue;
20973
+ const matcher = matcherKey(group.matcher);
20974
+ for (const handler of group.hooks) if (isPlainObject$1(handler)) owned.push({
20975
+ event,
20976
+ matcher,
20977
+ identity: handlerIdentity(handler)
20978
+ });
20979
+ }
20980
+ }
20981
+ return owned;
20982
+ }
20983
+ function mergeMatcherGroups({ existing, generated, event, seen, previouslyOwned, logger }) {
20984
+ const merged = [...generated];
20985
+ for (const group of existing) {
20986
+ if (!isMatcherGroup(group)) {
20987
+ warnSkip({
20988
+ logger,
20989
+ event,
20990
+ expected: "a matcher group"
20991
+ });
20992
+ continue;
20993
+ }
20994
+ const matcher = matcherKey(group.matcher);
20995
+ const preserved = group.hooks.filter((handler) => shouldPreserve({
20996
+ handler,
20997
+ event,
20998
+ matcher,
20999
+ seen,
21000
+ previouslyOwned,
21001
+ logger
21002
+ })).map((handler) => cloneHandler(handler));
21003
+ if (preserved.length === 0) continue;
21004
+ const target = merged.find((candidate) => isMatcherGroup(candidate) && matcherKey(candidate.matcher) === matcher);
21005
+ if (target !== void 0 && isMatcherGroup(target)) target.hooks.push(...preserved);
21006
+ else merged.push({
21007
+ ...omitPrototypePollutionKeys(group),
21008
+ hooks: preserved
21009
+ });
21010
+ }
21011
+ return merged;
21012
+ }
21013
+ function mergeFlatHandlers({ existing, generated, event, seen, previouslyOwned, logger }) {
21014
+ const preserved = [];
21015
+ for (const handler of existing) {
21016
+ if (isMatcherGroup(handler)) {
21017
+ warnSkip({
21018
+ logger,
21019
+ event,
21020
+ expected: "a flat handler"
21021
+ });
21022
+ continue;
21023
+ }
21024
+ if (!shouldPreserve({
21025
+ handler,
21026
+ event,
21027
+ matcher: void 0,
21028
+ seen,
21029
+ previouslyOwned,
21030
+ logger
21031
+ })) continue;
21032
+ preserved.push(cloneHandler(handler));
21033
+ }
21034
+ return [...generated, ...preserved];
21035
+ }
21036
+ function isMatcherGroup(value) {
21037
+ return isPlainObject$1(value) && Array.isArray(value.hooks);
21038
+ }
21039
+ function cloneMatcherGroup(group) {
21040
+ if (!isMatcherGroup(group)) return cloneHandler(group);
21041
+ return {
21042
+ ...group,
21043
+ hooks: group.hooks.map((handler) => cloneHandler(handler))
21044
+ };
21045
+ }
21046
+ function cloneHandler(handler) {
21047
+ return isPlainObject$1(handler) ? omitPrototypePollutionKeys(handler) : handler;
21048
+ }
21049
+ /**
21050
+ * Decide one existing handler, and remember it so an exact duplicate later in
21051
+ * the same event is not appended twice.
21052
+ */
21053
+ function shouldPreserve({ handler, event, matcher, seen, previouslyOwned, logger }) {
21054
+ if (!isPlainObject$1(handler)) {
21055
+ warnSkip({
21056
+ logger,
21057
+ event,
21058
+ expected: "a hook object"
21059
+ });
21060
+ return false;
21061
+ }
21062
+ const identity = handlerIdentity(handler);
21063
+ const key = ownedHookKey({
21064
+ event,
21065
+ matcher,
21066
+ identity
21067
+ });
21068
+ if (seen.has(key)) return false;
21069
+ if (previouslyOwned.has(key)) {
21070
+ logger?.warn(`Removing hook rulesync no longer generates on ${event}: ${identity}`);
21071
+ return false;
21072
+ }
21073
+ seen.add(key);
21074
+ logger?.warn(`Preserving unowned hook on ${event}: ${identity}`);
21075
+ return true;
21076
+ }
21077
+ /**
21078
+ * A handler's identity is its action, not its whole shape: two entries running
21079
+ * the same command are the same hook even if their timeouts differ, so the
21080
+ * generated one replaces the existing one instead of doubling it. Shapes with
21081
+ * no recognizable action fall back to their full structure, which keeps them
21082
+ * comparable across runs — the alternative, no identity at all, made them
21083
+ * accumulate on every generate.
21084
+ */
21085
+ function handlerIdentity(handler) {
21086
+ const type = typeof handler.type === "string" ? handler.type : inferredType(handler);
21087
+ switch (type) {
21088
+ case "http": return taggedIdentity("http", handler.url) ?? structuralIdentity(handler);
21089
+ case "mcp_tool": return mcpToolIdentity(handler) ?? structuralIdentity(handler);
21090
+ case "prompt":
21091
+ case "agent": return taggedIdentity(type, handler.prompt) ?? structuralIdentity(handler);
21092
+ case "function": return taggedIdentity("function", handler.name) ?? structuralIdentity(handler);
21093
+ default: return taggedIdentity("command", handler.command) ?? taggedIdentity("prompt", handler.prompt) ?? structuralIdentity(handler);
21094
+ }
21095
+ }
21096
+ function inferredType(handler) {
21097
+ return typeof handler.url === "string" ? "http" : "command";
21098
+ }
21099
+ function taggedIdentity(kind, value) {
21100
+ return typeof value === "string" && value !== "" ? `${kind}:${value}` : void 0;
21101
+ }
21102
+ function mcpToolIdentity(handler) {
21103
+ const server = typeof handler.server === "string" ? handler.server : "";
21104
+ const tool = typeof handler.tool === "string" ? handler.tool : "";
21105
+ if (server === "" && tool === "") return;
21106
+ return `mcp_tool:${server}:${tool}:${stableStringify$1(handler.input)}`;
21107
+ }
21108
+ function structuralIdentity(handler) {
21109
+ return `json:${stableStringify$1(handler)}`;
21110
+ }
21111
+ /** Key-order-independent JSON, so an identity survives a rewritten file. */
21112
+ function stableStringify$1(value) {
21113
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify$1(entry)).join(",")}]`;
21114
+ if (isPlainObject$1(value)) return `{${Object.entries(value).filter(([key]) => !isPrototypePollutionKey(key)).toSorted(([left], [right]) => left === right ? 0 : left < right ? -1 : 1).map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify$1(entry)}`).join(",")}}`;
21115
+ return JSON.stringify(value) ?? "null";
21116
+ }
21117
+ /**
21118
+ * Matcher groups are matched by the whole matcher value, not just strings: a
21119
+ * non-string matcher is a shape rulesync does not emit, and collapsing every
21120
+ * such group onto one key would merge unrelated third-party groups together.
21121
+ */
21122
+ function matcherKey(matcher) {
21123
+ return matcher === void 0 ? "" : stableStringify$1(matcher);
21124
+ }
21125
+ function warnSkip({ logger, event, expected }) {
21126
+ logger?.warn(`Skipping existing hook entry on ${event}: expected ${expected}`);
21127
+ }
21128
+ //#endregion
20673
21129
  //#region src/features/hooks/claudecode-hooks.ts
20674
21130
  const CLAUDE_CONVERTER_CONFIG = {
20675
21131
  supportedEvents: CLAUDE_HOOK_EVENTS,
@@ -20738,7 +21194,7 @@ const CLAUDE_CONVERTER_CONFIG = {
20738
21194
  commandOnly: true
20739
21195
  }]
20740
21196
  };
20741
- var ClaudecodeHooks = class extends ToolHooks {
21197
+ var ClaudecodeHooks = class ClaudecodeHooks extends ToolHooks {
20742
21198
  constructor(params) {
20743
21199
  super({
20744
21200
  ...params,
@@ -20773,7 +21229,13 @@ var ClaudecodeHooks = class extends ToolHooks {
20773
21229
  validate
20774
21230
  });
20775
21231
  }
20776
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
21232
+ static supportsPreserveUnowned() {
21233
+ return true;
21234
+ }
21235
+ static async getAuxiliaryFiles({ toolHooks } = {}) {
21236
+ return toolHooks instanceof ClaudecodeHooks ? toolHooks.getOwnershipLockFiles() : [];
21237
+ }
21238
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
20777
21239
  const paths = this.getSettablePaths({ global });
20778
21240
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
20779
21241
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -20784,11 +21246,20 @@ var ClaudecodeHooks = class extends ToolHooks {
20784
21246
  converterConfig: this.getConverterConfig(),
20785
21247
  logger
20786
21248
  });
21249
+ const preserving = preserveUnowned && this.supportsPreserveUnowned();
21250
+ const merged = mergeGeneratedHookLists({
21251
+ existingContent,
21252
+ generatedHooks: claudeHooks,
21253
+ shape: "matcher-groups",
21254
+ preserveUnowned: preserving,
21255
+ previouslyOwned: preserving ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
21256
+ logger
21257
+ });
20787
21258
  const fileContent = applySharedConfigPatch({
20788
21259
  fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
20789
21260
  feature: "hooks",
20790
21261
  existingContent,
20791
- patch: { hooks: claudeHooks },
21262
+ patch: { hooks: merged.hooks },
20792
21263
  filePath
20793
21264
  });
20794
21265
  return new this({
@@ -20796,6 +21267,7 @@ var ClaudecodeHooks = class extends ToolHooks {
20796
21267
  relativeDirPath: paths.relativeDirPath,
20797
21268
  relativeFilePath: paths.relativeFilePath,
20798
21269
  fileContent,
21270
+ ownedHookRefs: preserving ? merged.owned : void 0,
20799
21271
  validate
20800
21272
  });
20801
21273
  }
@@ -20853,6 +21325,14 @@ var ClaudecodePluginHooks = class extends ClaudecodeHooks {
20853
21325
  projectDirVar: "$CLAUDE_PLUGIN_ROOT"
20854
21326
  };
20855
21327
  }
21328
+ /**
21329
+ * The plugin bundle is generated in full by rulesync — nothing else writes
21330
+ * into it — so preserving there would buy nothing and cost the ability to
21331
+ * remove a hook.
21332
+ */
21333
+ static supportsPreserveUnowned() {
21334
+ return false;
21335
+ }
20856
21336
  static getSettablePaths() {
20857
21337
  return {
20858
21338
  relativeDirPath: CLAUDECODE_PLUGIN_HOOKS_DIR,
@@ -21283,21 +21763,30 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
21283
21763
  validate
21284
21764
  });
21285
21765
  }
21286
- static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
21766
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
21287
21767
  const paths = CodexcliHooks.getSettablePaths({ global });
21768
+ const existingContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "";
21288
21769
  const config = rulesyncHooks.getJson();
21289
- const codexHooks = canonicalToToolHooks({
21290
- config,
21291
- toolOverrideHooks: config.codexcli?.hooks,
21292
- converterConfig: CODEXCLI_CONVERTER_CONFIG,
21770
+ const merged = mergeGeneratedHookLists({
21771
+ existingContent,
21772
+ generatedHooks: canonicalToToolHooks({
21773
+ config,
21774
+ toolOverrideHooks: config.codexcli?.hooks,
21775
+ converterConfig: CODEXCLI_CONVERTER_CONFIG,
21776
+ logger
21777
+ }),
21778
+ shape: "matcher-groups",
21779
+ preserveUnowned,
21780
+ previouslyOwned: preserveUnowned ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
21293
21781
  logger
21294
21782
  });
21295
- const fileContent = JSON.stringify({ hooks: codexHooks }, null, 2);
21783
+ const fileContent = JSON.stringify({ hooks: merged.hooks }, null, 2);
21296
21784
  return new CodexcliHooks({
21297
21785
  outputRoot,
21298
21786
  relativeDirPath: paths.relativeDirPath,
21299
21787
  relativeFilePath: paths.relativeFilePath,
21300
21788
  fileContent,
21789
+ ownedHookRefs: preserveUnowned ? merged.owned : void 0,
21301
21790
  validate
21302
21791
  });
21303
21792
  }
@@ -21333,8 +21822,11 @@ var CodexcliHooks = class CodexcliHooks extends ToolHooks {
21333
21822
  validate: false
21334
21823
  });
21335
21824
  }
21336
- static async getAuxiliaryFiles({ outputRoot = process.cwd() } = {}) {
21337
- return [await CodexcliConfigToml.fromOutputRoot({ outputRoot })];
21825
+ static supportsPreserveUnowned() {
21826
+ return true;
21827
+ }
21828
+ static async getAuxiliaryFiles({ outputRoot = process.cwd(), toolHooks } = {}) {
21829
+ return [await CodexcliConfigToml.fromOutputRoot({ outputRoot }), ...toolHooks instanceof CodexcliHooks ? toolHooks.getOwnershipLockFiles() : []];
21338
21830
  }
21339
21831
  };
21340
21832
  //#endregion
@@ -21939,7 +22431,7 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21939
22431
  validate
21940
22432
  });
21941
22433
  }
21942
- static fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false }) {
22434
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, preserveUnowned = false, logger }) {
21943
22435
  const config = rulesyncHooks.getJson();
21944
22436
  const cursorSupported = new Set(CURSOR_HOOK_EVENTS);
21945
22437
  const sharedHooks = {};
@@ -21966,21 +22458,36 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21966
22458
  }));
21967
22459
  if (mappedDefs.length > 0) mappedHooks[cursorEventName] = mappedDefs;
21968
22460
  }
22461
+ const paths = CursorHooks.getSettablePaths({ global });
22462
+ const merged = mergeGeneratedHookLists({
22463
+ existingContent: await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "",
22464
+ generatedHooks: mappedHooks,
22465
+ shape: "flat",
22466
+ preserveUnowned,
22467
+ previouslyOwned: preserveUnowned ? parseHooksOwnershipLock(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, HOOKS_OWNERSHIP_LOCK_FILE_NAME))) : void 0,
22468
+ logger
22469
+ });
21969
22470
  const cursorConfig = {
21970
22471
  version: config.version ?? 1,
21971
- hooks: mappedHooks
22472
+ hooks: merged.hooks
21972
22473
  };
21973
22474
  const fileContent = JSON.stringify(cursorConfig, null, 2);
21974
- const paths = CursorHooks.getSettablePaths({ global });
21975
22475
  return new CursorHooks({
21976
22476
  outputRoot,
21977
22477
  relativeDirPath: paths.relativeDirPath,
21978
22478
  relativeFilePath: paths.relativeFilePath,
21979
22479
  fileContent,
22480
+ ownedHookRefs: preserveUnowned ? merged.owned : void 0,
21980
22481
  validate,
21981
22482
  rulesyncHooks
21982
22483
  });
21983
22484
  }
22485
+ static supportsPreserveUnowned() {
22486
+ return true;
22487
+ }
22488
+ static async getAuxiliaryFiles({ toolHooks } = {}) {
22489
+ return toolHooks instanceof CursorHooks ? toolHooks.getOwnershipLockFiles() : [];
22490
+ }
21984
22491
  toRulesyncHooks() {
21985
22492
  const content = this.getFileContent();
21986
22493
  const parsed = JSON.parse(content);
@@ -25220,6 +25727,186 @@ var VibeHooks = class VibeHooks extends ToolHooks {
25220
25727
  }
25221
25728
  };
25222
25729
  //#endregion
25730
+ //#region src/features/hooks/zcode-hooks.ts
25731
+ /**
25732
+ * Single spelling of the config.json codec/policy, matching the one in
25733
+ * ZcodeMcp: fail closed on an unparseable root rather than replacing the
25734
+ * user's primary ZCode config with generated output.
25735
+ */
25736
+ function parseZcodeConfig$1(fileContent, filePath) {
25737
+ return parseSharedConfig({
25738
+ format: "json",
25739
+ fileContent,
25740
+ filePath,
25741
+ invalidRootPolicy: "error"
25742
+ });
25743
+ }
25744
+ /**
25745
+ * Drop ZCode's native `process` hooks from an `events` map before the shared
25746
+ * import converter runs. The converter would coerce the unknown type to
25747
+ * `command` and import the executable alone — losing the `args` vector that
25748
+ * *is* the process hook's command line — so the imported hook would run
25749
+ * something else than the file states. Matcher groups left without hooks are
25750
+ * dropped with them.
25751
+ */
25752
+ function stripProcessHooks({ events, logger }) {
25753
+ if (!isRecord$1(events)) return events;
25754
+ const result = Object.create(null);
25755
+ for (const [eventName, entries] of Object.entries(events)) {
25756
+ if (!Array.isArray(entries)) {
25757
+ result[eventName] = entries;
25758
+ continue;
25759
+ }
25760
+ const kept = [];
25761
+ for (const entry of entries) {
25762
+ if (!isRecord$1(entry) || !Array.isArray(entry.hooks)) {
25763
+ kept.push(entry);
25764
+ continue;
25765
+ }
25766
+ const hooks = entry.hooks.filter((hook) => {
25767
+ if (isRecord$1(hook) && hook.type === "process") {
25768
+ logger?.warn(`Skipping a ZCode "process" hook on "${eventName}" while importing: it has no canonical equivalent, and importing its executable alone would change what it runs.`);
25769
+ return false;
25770
+ }
25771
+ return true;
25772
+ });
25773
+ if (hooks.length > 0) kept.push({
25774
+ ...entry,
25775
+ hooks
25776
+ });
25777
+ }
25778
+ result[eventName] = kept;
25779
+ }
25780
+ return result;
25781
+ }
25782
+ const ZCODE_CONVERTER_CONFIG = {
25783
+ supportedEvents: ZCODE_HOOK_EVENTS,
25784
+ canonicalToToolEventNames: CANONICAL_TO_ZCODE_EVENT_NAMES,
25785
+ toolToCanonicalEventNames: ZCODE_TO_CANONICAL_EVENT_NAMES,
25786
+ projectDirVar: "",
25787
+ noMatcherEvents: /* @__PURE__ */ new Set(["beforeSubmitPrompt", "stop"]),
25788
+ supportedHookTypes: /* @__PURE__ */ new Set(["command"]),
25789
+ booleanPassthroughFields: [{
25790
+ canonical: "async",
25791
+ tool: "async"
25792
+ }, {
25793
+ canonical: "enabled",
25794
+ tool: "enabled"
25795
+ }],
25796
+ stringPassthroughFields: [{
25797
+ canonical: "statusMessage",
25798
+ tool: "statusMessage"
25799
+ }, {
25800
+ canonical: "shell",
25801
+ tool: "shell",
25802
+ commandOnly: true
25803
+ }],
25804
+ wildcardMatcherMeansAll: true
25805
+ };
25806
+ /**
25807
+ * ZCode hooks.
25808
+ *
25809
+ * ZCode reads configuration-file hooks from the `hooks` block of its user
25810
+ * config file, `~/.zcode/cli/config.json`. Workspace config hooks are never
25811
+ * executed — the workspace file is ignored regardless of `hooks.enabled` — so
25812
+ * rulesync treats ZCode hooks as global-only. The event map is nested under
25813
+ * `hooks.events` beside the user-tunable `enabled` and `timeoutMs` siblings,
25814
+ * which are carried over while `events` is replaced.
25815
+ *
25816
+ * @see https://zcode.z.ai/en/docs
25817
+ */
25818
+ var ZcodeHooks = class ZcodeHooks extends ToolHooks {
25819
+ json;
25820
+ constructor(params) {
25821
+ super(params);
25822
+ this.json = parseZcodeConfig$1(this.fileContent ?? "{}", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
25823
+ }
25824
+ isDeletable() {
25825
+ return false;
25826
+ }
25827
+ static getSettablePaths(_options = {}) {
25828
+ return {
25829
+ relativeDirPath: ZCODE_GLOBAL_CONFIG_DIR_PATH,
25830
+ relativeFilePath: ZCODE_CONFIG_FILE_NAME
25831
+ };
25832
+ }
25833
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
25834
+ const paths = this.getSettablePaths({ global });
25835
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{}";
25836
+ return new ZcodeHooks({
25837
+ outputRoot,
25838
+ relativeDirPath: paths.relativeDirPath,
25839
+ relativeFilePath: paths.relativeFilePath,
25840
+ fileContent,
25841
+ validate,
25842
+ global
25843
+ });
25844
+ }
25845
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
25846
+ const paths = this.getSettablePaths({ global });
25847
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
25848
+ const existingContent = await readFileContentOrNull(filePath) ?? "{}";
25849
+ const existing = parseZcodeConfig$1(existingContent, filePath);
25850
+ const config = rulesyncHooks.getJson();
25851
+ const events = canonicalToToolHooks({
25852
+ config,
25853
+ toolOverrideHooks: config.zcode?.hooks,
25854
+ converterConfig: ZCODE_CONVERTER_CONFIG,
25855
+ logger
25856
+ });
25857
+ const existingHooks = isRecord$1(existing["hooks"]) ? existing[ZCODE_HOOKS_CONFIG_KEY] : {};
25858
+ const shouldStateEnabled = Object.keys(events).length > 0 && existingHooks.enabled === void 0;
25859
+ return new ZcodeHooks({
25860
+ outputRoot,
25861
+ relativeDirPath: paths.relativeDirPath,
25862
+ relativeFilePath: paths.relativeFilePath,
25863
+ fileContent: applySharedConfigPatch({
25864
+ fileKey: sharedConfigFileKey(paths),
25865
+ feature: "hooks",
25866
+ existingContent,
25867
+ patch: { [ZCODE_HOOKS_CONFIG_KEY]: {
25868
+ ...existingHooks,
25869
+ ...shouldStateEnabled ? { enabled: true } : {},
25870
+ [ZCODE_HOOKS_EVENTS_KEY]: events
25871
+ } },
25872
+ filePath
25873
+ }),
25874
+ validate,
25875
+ global
25876
+ });
25877
+ }
25878
+ toRulesyncHooks({ logger } = {}) {
25879
+ const hooks = toolHooksToCanonical({
25880
+ hooks: stripProcessHooks({
25881
+ events: (isRecord$1(this.json["hooks"]) ? this.json[ZCODE_HOOKS_CONFIG_KEY] : {})[ZCODE_HOOKS_EVENTS_KEY],
25882
+ logger
25883
+ }),
25884
+ converterConfig: ZCODE_CONVERTER_CONFIG,
25885
+ logger
25886
+ });
25887
+ return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
25888
+ hooks,
25889
+ overrideKey: "zcode"
25890
+ }), null, 2) });
25891
+ }
25892
+ validate() {
25893
+ return {
25894
+ success: true,
25895
+ error: null
25896
+ };
25897
+ }
25898
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
25899
+ return new ZcodeHooks({
25900
+ outputRoot,
25901
+ relativeDirPath,
25902
+ relativeFilePath,
25903
+ fileContent: JSON.stringify({ [ZCODE_HOOKS_CONFIG_KEY]: { [ZCODE_HOOKS_EVENTS_KEY]: {} } }, null, 2),
25904
+ validate: false,
25905
+ global
25906
+ });
25907
+ }
25908
+ };
25909
+ //#endregion
25223
25910
  //#region src/features/hooks/hooks-processor.ts
25224
25911
  const HooksProcessorToolTargetSchema = zod_mini.z.enum(hooksProcessorToolTargetTuple);
25225
25912
  /**
@@ -25291,8 +25978,12 @@ const HOOKS_OVERRIDE_KEY_ALIASES = {
25291
25978
  "kiro-cli": KIRO_HOOKS_OVERRIDE_KEY,
25292
25979
  "kiro-ide": KIRO_HOOKS_OVERRIDE_KEY
25293
25980
  };
25294
- /** The targets writing the standalone `.kiro/hooks/*.json` v1 format. */
25295
- const KIRO_STANDALONE_HOOKS_TARGETS = /* @__PURE__ */ new Set(["kiro-cli", "kiro-ide"]);
25981
+ /** The targets whose hooks format carries a per-hook on-disk enable flag. */
25982
+ const PER_HOOK_ENABLED_TARGETS = /* @__PURE__ */ new Set([
25983
+ "kiro-cli",
25984
+ "kiro-ide",
25985
+ "zcode"
25986
+ ]);
25296
25987
  const toolHooksFactories = /* @__PURE__ */ new Map([
25297
25988
  ["amp", {
25298
25989
  class: AmpHooks,
@@ -25641,6 +26332,17 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
25641
26332
  supportedEvents: GROKCLI_HOOK_EVENTS,
25642
26333
  supportedHookTypes: ["command", "http"],
25643
26334
  supportsMatcher: true
26335
+ }],
26336
+ ["zcode", {
26337
+ class: ZcodeHooks,
26338
+ meta: {
26339
+ supportsProject: false,
26340
+ supportsGlobal: true,
26341
+ supportsImport: true
26342
+ },
26343
+ supportedEvents: ZCODE_HOOK_EVENTS,
26344
+ supportedHookTypes: ["command"],
26345
+ supportsMatcher: true
25644
26346
  }]
25645
26347
  ]);
25646
26348
  const hooksProcessorToolTargets = [...toolHooksFactories.entries()].filter(([, f]) => f.meta.supportsProject).map(([t]) => t);
@@ -25650,7 +26352,8 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
25650
26352
  var HooksProcessor = class extends FeatureProcessor {
25651
26353
  toolTarget;
25652
26354
  global;
25653
- constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
26355
+ preserveUnownedHooks;
26356
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, preserveUnownedHooks = false, logger }) {
25654
26357
  super({
25655
26358
  outputRoot,
25656
26359
  inputRoots,
@@ -25661,6 +26364,7 @@ var HooksProcessor = class extends FeatureProcessor {
25661
26364
  if (!result.success) throw new Error(`Invalid tool target for HooksProcessor: ${toolTarget}. ${formatError(result.error)}`);
25662
26365
  this.toolTarget = result.data;
25663
26366
  this.global = global;
26367
+ this.preserveUnownedHooks = preserveUnownedHooks;
25664
26368
  }
25665
26369
  async loadRulesyncFiles() {
25666
26370
  const relativePaths = getRulesyncSourceCandidates({ paths: RulesyncHooks.getSettablePaths() }).map((candidate) => candidate.relativeFilePath);
@@ -25752,14 +26456,14 @@ var HooksProcessor = class extends FeatureProcessor {
25752
26456
  }
25753
26457
  for (const [hookType, events] of unsupportedTypeToEvents) this.logger.warn(`Skipped ${hookType}-type hook(s) for ${this.toolTarget} (not supported): ${Array.from(events).join(", ")}`);
25754
26458
  }
25755
- if (!KIRO_STANDALONE_HOOKS_TARGETS.has(this.toolTarget)) {
26459
+ if (!PER_HOOK_ENABLED_TARGETS.has(this.toolTarget)) {
25756
26460
  const skippedEvents = new Set(unsupportedEventNames({
25757
26461
  factory,
25758
26462
  sharedHooks,
25759
26463
  effectiveHooks
25760
26464
  }));
25761
26465
  const eventsWithDisabledHooks = Object.entries(sharedHooks).filter(([event, defs]) => !skippedEvents.has(event) && defs.some((def) => def.enabled === false)).map(([event]) => event);
25762
- if (eventsWithDisabledHooks.length > 0) this.logger.warn(`Emitting "enabled: false" hook(s) as active for ${this.toolTarget} (only the kiro-cli / kiro-ide standalone hooks format supports the flag): ${eventsWithDisabledHooks.join(", ")}`);
26466
+ if (eventsWithDisabledHooks.length > 0) this.logger.warn(`Emitting "enabled: false" hook(s) as active for ${this.toolTarget} (only the kiro-cli / kiro-ide / zcode hooks formats support the flag): ${eventsWithDisabledHooks.join(", ")}`);
25763
26467
  }
25764
26468
  const eventsWithUnsupportedMatcher = unsupportedMatcherEventNames({
25765
26469
  factory,
@@ -25771,6 +26475,7 @@ var HooksProcessor = class extends FeatureProcessor {
25771
26475
  rulesyncHooks,
25772
26476
  validate: true,
25773
26477
  global: this.global,
26478
+ preserveUnowned: this.preserveUnownedHooks,
25774
26479
  logger: withToolTargetPrefix({
25775
26480
  logger: this.logger,
25776
26481
  toolTarget: this.toolTarget
@@ -69002,6 +69707,7 @@ async function generateHooksCore(params) {
69002
69707
  toolTarget,
69003
69708
  global: config.getGlobal(),
69004
69709
  dryRun: config.isPreviewMode(),
69710
+ preserveUnownedHooks: config.getPreserveUnownedHooks(),
69005
69711
  logger
69006
69712
  });
69007
69713
  const result = await processFeatureWithRulesyncFiles({