rulesync 16.38.0 → 16.39.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,36 +444,36 @@ 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$351 } = node_path.posix;
447
+ const { join: join$352 } = 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_USER_CONFIG_DIR_NAME = "rulesync";
451
451
  const RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME = ".config";
452
452
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
453
453
  const RULES_FEATURE_SUBDIR = "rules";
454
- const CURATED_RULES_FEATURE_SUBDIR = join$351(RULES_FEATURE_SUBDIR, ".curated");
454
+ const CURATED_RULES_FEATURE_SUBDIR = join$352(RULES_FEATURE_SUBDIR, ".curated");
455
455
  const COMMANDS_FEATURE_SUBDIR = "commands";
456
456
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
457
457
  const CHECKS_FEATURE_SUBDIR = "checks";
458
458
  const SKILLS_FEATURE_SUBDIR = "skills";
459
- const CURATED_SKILLS_FEATURE_SUBDIR = join$351(SKILLS_FEATURE_SUBDIR, ".curated");
460
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
- join$351(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
459
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$352(SKILLS_FEATURE_SUBDIR, ".curated");
460
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
461
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
462
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
463
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
464
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
465
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
466
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
467
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
468
+ join$352(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
469
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
470
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
471
471
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
472
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
472
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
473
473
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
474
474
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
475
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$351(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
475
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
476
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$352(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
477
477
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
478
478
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
479
479
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -696,6 +696,7 @@ const subagentsProcessorToolTargetTuple = [
696
696
  "kiro-cli",
697
697
  "kiro-ide",
698
698
  "opencode",
699
+ "pool",
699
700
  "qwencode",
700
701
  "reasonix",
701
702
  "roo",
@@ -777,6 +778,7 @@ const hooksProcessorToolTargetTuple = [
777
778
  "crush",
778
779
  "opencode",
779
780
  "pi",
781
+ "pool",
780
782
  "factorydroid",
781
783
  "goose",
782
784
  "hermesagent",
@@ -824,6 +826,7 @@ const permissionsProcessorToolTargetTuple = [
824
826
  "kiro-ide",
825
827
  "opencode",
826
828
  "pi",
829
+ "pool",
827
830
  "qwencode",
828
831
  "reasonix",
829
832
  "roo",
@@ -1380,6 +1383,111 @@ async function resolvedPathEscapesRoot({ rootPath, targetPath }) {
1380
1383
  }));
1381
1384
  }
1382
1385
  /**
1386
+ * How many links {@link writeLandingPath} follows before it treats the chain as
1387
+ * a cycle it cannot write through. Linux itself gives up after 40.
1388
+ */
1389
+ const MAX_LINK_HOPS = 40;
1390
+ /**
1391
+ * Whether `error` is the ENOTDIR a lookup through a file raises. On the way to
1392
+ * a write it means the same as a missing entry: nothing at or below the path
1393
+ * exists, so nothing there can be a link.
1394
+ */
1395
+ function isNotADirectoryError(error) {
1396
+ return someErrorInChain(error, (candidate) => "code" in candidate && candidate.code === "ENOTDIR");
1397
+ }
1398
+ /**
1399
+ * Where a write to `targetPath` lands once the OS has followed every link on
1400
+ * the way there, or `null` when the links form a cycle no write can get
1401
+ * through. The target itself usually does not exist yet, so the walk goes
1402
+ * segment by segment from the filesystem root: an existing segment that is a
1403
+ * link is replaced by what it resolves to, and a dangling link — one whose
1404
+ * target does not exist yet, which is exactly the shape of a link planted at
1405
+ * `AGENTS.md` pointing at a `~/.zshenv` that is still to be created — has its
1406
+ * target spliced into the walk in its place, so the segments of that target
1407
+ * are looked up one by one too. Once a segment is missing, nothing below it
1408
+ * exists, and the rest of the path is where the write's `mkdir -p` will put it.
1409
+ *
1410
+ * Walking segment by segment is what keeps a `..` honest. A link target is a
1411
+ * path the OS resolves left to right, so in `sub/../victim` the `..` climbs out
1412
+ * of wherever `sub` leads, not out of the directory `sub` is spelled in; a
1413
+ * lexical `resolve` folds `sub/..` away before `sub` is ever looked at, which
1414
+ * is how a link chain could be spelled to pass the check and still land
1415
+ * outside the root.
1416
+ */
1417
+ async function writeLandingPath(targetPath) {
1418
+ const start = splitAbsolutePath((0, node_path.isAbsolute)(targetPath) ? targetPath : (0, node_path.resolve)(targetPath));
1419
+ let current = start.root;
1420
+ let pending = start.segments;
1421
+ let exists = true;
1422
+ let linkHops = 0;
1423
+ while (pending.length > 0) {
1424
+ const segment = pending.shift();
1425
+ if (segment === void 0 || segment === "" || segment === ".") continue;
1426
+ if (segment === "..") {
1427
+ current = (0, node_path.dirname)(current);
1428
+ continue;
1429
+ }
1430
+ current = (0, node_path.join)(current, segment);
1431
+ if (!exists) continue;
1432
+ let stats;
1433
+ try {
1434
+ stats = await (0, node_fs_promises.lstat)(current);
1435
+ } catch (error) {
1436
+ if (!isFileNotFoundError(error) && !isNotADirectoryError(error)) throw error;
1437
+ exists = false;
1438
+ continue;
1439
+ }
1440
+ if (!stats.isSymbolicLink()) continue;
1441
+ linkHops += 1;
1442
+ if (linkHops > MAX_LINK_HOPS) return null;
1443
+ try {
1444
+ current = await (0, node_fs_promises.realpath)(current);
1445
+ continue;
1446
+ } catch {}
1447
+ const linkTarget = await (0, node_fs_promises.readlink)(current);
1448
+ if ((0, node_path.isAbsolute)(linkTarget)) {
1449
+ const target = splitAbsolutePath(linkTarget);
1450
+ current = target.root;
1451
+ pending = [...target.segments, ...pending];
1452
+ } else {
1453
+ current = (0, node_path.dirname)(current);
1454
+ pending = [...splitPathSegments(linkTarget), ...pending];
1455
+ }
1456
+ }
1457
+ return current;
1458
+ }
1459
+ /**
1460
+ * Split an absolute path into its root and its raw segments, without folding a
1461
+ * `..` away: {@link writeLandingPath} has to see every `..` where it is spelled.
1462
+ */
1463
+ function splitAbsolutePath(absolutePath) {
1464
+ const root = (0, node_path.parse)(absolutePath).root;
1465
+ return {
1466
+ root,
1467
+ segments: splitPathSegments(absolutePath.slice(root.length))
1468
+ };
1469
+ }
1470
+ /**
1471
+ * Whether writing `targetPath` would land outside `rootPath` once every link on
1472
+ * the way there is resolved. Both sides are taken to where a write to them
1473
+ * would land (see {@link writeLandingPath}), so a root that does not fully
1474
+ * exist yet is judged the same way as the target below it.
1475
+ *
1476
+ * A link that stays inside the root passes. That is what sets this apart from
1477
+ * the stricter {@link assertWritablePathInsideRoot} the sweep paths use: a
1478
+ * dotfiles checkout linked from inside the home directory is a common shape for
1479
+ * a global write target, and refusing it would refuse `--global` outright.
1480
+ *
1481
+ * A link chain that cycles is reported as an escape: the write could not get
1482
+ * through it, and refusing it is the answer that never writes somewhere
1483
+ * unexpected.
1484
+ */
1485
+ async function writablePathEscapesRoot({ rootPath, targetPath }) {
1486
+ const [rootLanding, targetLanding] = await Promise.all([writeLandingPath(rootPath), writeLandingPath(targetPath)]);
1487
+ if (rootLanding === null || targetLanding === null) return true;
1488
+ return pathEscapesRoot((0, node_path.relative)(rootLanding, targetLanding));
1489
+ }
1490
+ /**
1383
1491
  * How many trailing segments `filePath` and `identity` have in common.
1384
1492
  *
1385
1493
  * A path that walked through no link at all shares all of its own segments with
@@ -4679,6 +4787,34 @@ const VIBE_HOOK_EVENTS = [
4679
4787
  "stop"
4680
4788
  ];
4681
4789
  /**
4790
+ * Hook events supported by Pool.
4791
+ *
4792
+ * Pool's `hooks` settings block fires six events, spelled the Claude Code
4793
+ * way: `PreToolUse`, `PostToolUse`, `UserPromptSubmit` (← `beforeSubmitPrompt`),
4794
+ * `PreCompact`, `SessionStart` and `Stop`. Every entry carries a `matcher`
4795
+ * (required by Pool's schema; `""`/`*` match any tool, a bare name is an exact
4796
+ * match, `a|b` lists names, anything else is a regular expression), which only
4797
+ * `PreToolUse`/`PostToolUse` test against the tool name.
4798
+ * @see https://docs.poolside.ai/hooks
4799
+ */
4800
+ const POOL_HOOK_EVENTS = [
4801
+ "preToolUse",
4802
+ "postToolUse",
4803
+ "beforeSubmitPrompt",
4804
+ "preCompact",
4805
+ "sessionStart",
4806
+ "stop"
4807
+ ];
4808
+ const CANONICAL_TO_POOL_EVENT_NAMES = {
4809
+ preToolUse: "PreToolUse",
4810
+ postToolUse: "PostToolUse",
4811
+ beforeSubmitPrompt: "UserPromptSubmit",
4812
+ preCompact: "PreCompact",
4813
+ sessionStart: "SessionStart",
4814
+ stop: "Stop"
4815
+ };
4816
+ const POOL_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_POOL_EVENT_NAMES).map(([k, v]) => [v, k]));
4817
+ /**
4682
4818
  * Hook events supported by Crush.
4683
4819
  *
4684
4820
  * Crush's `hooks` config block currently fires a single event, `PreToolUse`
@@ -5273,6 +5409,7 @@ const HooksConfigSchema = zod_mini.z.looseObject({
5273
5409
  "kimi-code": zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5274
5410
  zcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5275
5411
  crush: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5412
+ pool: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5276
5413
  bob: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5277
5414
  cortexcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
5278
5415
  commandcode: zod_mini.z.optional(zod_mini.z.looseObject({ hooks: zod_mini.z.optional(hooksRecordSchema) })),
@@ -8001,6 +8138,7 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
8001
8138
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
8002
8139
  crush: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
8003
8140
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
8141
+ pool: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
8004
8142
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
8005
8143
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
8006
8144
  roo: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
@@ -9843,6 +9981,22 @@ allowedMcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) })
9843
9981
  injectAgentsMd: zod_mini.z.optional(zod_mini.z.boolean()),
9844
9982
  mcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
9845
9983
  })),
9984
+ pool: zod_mini.z.optional(zod_mini.z.looseObject({
9985
+ type: zod_mini.z.optional(zod_mini.z.enum([
9986
+ "in_process",
9987
+ "command",
9988
+ "agent_server"
9989
+ ])),
9990
+ description: zod_mini.z.optional(zod_mini.z.string()),
9991
+ instructions: zod_mini.z.optional(zod_mini.z.string()),
9992
+ command: zod_mini.z.optional(zod_mini.z.string()),
9993
+ args: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
9994
+ env: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
9995
+ agent_server: zod_mini.z.optional(zod_mini.z.string()),
9996
+ session_config_options: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.unknown())),
9997
+ inherit_agent_config: zod_mini.z.optional(zod_mini.z.boolean()),
9998
+ disabled: zod_mini.z.optional(zod_mini.z.boolean())
9999
+ })),
9846
10000
  vibe: zod_mini.z.optional(zod_mini.z.looseObject({
9847
10001
  agent_type: zod_mini.z.optional(zod_mini.z.enum(["agent", "subagent"])),
9848
10002
  display_name: zod_mini.z.optional(zod_mini.z.string()),
@@ -10430,6 +10584,17 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
10430
10584
  this.logger = logger;
10431
10585
  }
10432
10586
  /**
10587
+ * Whether `convertRulesyncFilesToToolFiles` still yields tool files for an
10588
+ * empty source list. A processor that aggregates into a shared file it may
10589
+ * never delete (Pool's `settings.yaml` subagents) answers true so a run
10590
+ * whose last source file is gone retracts what an earlier run generated;
10591
+ * the default keeps a run with no source from writing anything. Consulted
10592
+ * after `loadRulesyncFiles`, so the answer may depend on what that load saw.
10593
+ */
10594
+ emitsToolFilesForEmptySource() {
10595
+ return false;
10596
+ }
10597
+ /**
10433
10598
  * Return tool targets that this feature supports.
10434
10599
  */
10435
10600
  static getToolTargets(_params = {}) {
@@ -10444,6 +10609,11 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
10444
10609
  const changedPaths = [];
10445
10610
  for (const aiFile of aiFiles) {
10446
10611
  const filePath = aiFile.getFilePath();
10612
+ if (await refusesWriteOutsideRoot({
10613
+ logger: this.logger,
10614
+ rootPath: aiFile.getOutputRoot(),
10615
+ targetPath: filePath
10616
+ })) continue;
10447
10617
  const existingFileContent = await readFileContentOrNull(filePath);
10448
10618
  if (existingFileContent !== null && aiFile.shouldMergeExistingFileContent()) aiFile.setFileContent(existingFileContent);
10449
10619
  const contentWithNewline = addTrailingNewline(aiFile.getFileContent());
@@ -10576,6 +10746,37 @@ function mergeByIdentity({ perRoot, identity }) {
10576
10746
  return order.map((key) => winnerByKey.get(key));
10577
10747
  }
10578
10748
  /**
10749
+ * Whether a write to `targetPath` has to be refused because a link on the way
10750
+ * there leads out of `rootPath`, warning about the refusal.
10751
+ *
10752
+ * A checked-out repository can carry a symbolic link at an output path
10753
+ * (`AGENTS.md -> ~/.bashrc`, a tool directory linked to `/etc`), and a write
10754
+ * through it would land wherever the link points. Every writer that lets the
10755
+ * OS follow links — file, directory, and config-rewrite alike — asks this first
10756
+ * so that one warning describes the one behavior.
10757
+ */
10758
+ async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
10759
+ if (!await writablePathEscapesRoot({
10760
+ rootPath,
10761
+ targetPath
10762
+ })) return false;
10763
+ logger.warn(`Refusing to write ${quoteForLog(targetPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
10764
+ return true;
10765
+ }
10766
+ /**
10767
+ * {@link refusesWriteOutsideRoot} over every path a single unit of output
10768
+ * writes, stopping at the first refusal: a directory written as one unit is
10769
+ * held back whole when any one file in it would land outside the root.
10770
+ */
10771
+ async function refusesAnyWriteOutsideRoot({ logger, rootPath, targetPaths }) {
10772
+ for (const targetPath of targetPaths) if (await refusesWriteOutsideRoot({
10773
+ logger,
10774
+ rootPath,
10775
+ targetPath
10776
+ })) return true;
10777
+ return false;
10778
+ }
10779
+ /**
10579
10780
  * The key two spellings share when a case-insensitive filesystem would give
10580
10781
  * them one file. `toLowerCase()` is locale-independent (unlike
10581
10782
  * `toLocaleLowerCase`, it does not turn `I` into the Turkish `ı` under a Turkish
@@ -13040,15 +13241,33 @@ const KIMI_CODE_CONFIG_DECLARATION = {
13040
13241
  * Pool settings (`.poolside/settings.yaml` / `~/.config/poolside/settings.yaml`):
13041
13242
  * the user's primary Pool settings file, so an unparseable root fails closed
13042
13243
  * rather than being replaced with generated output. `mcp_servers` is owned as
13043
- * a whole key because the MCP writer emits the complete server map.
13244
+ * a whole key because the MCP writer emits the complete server map. The
13245
+ * permissions writer deep-merges: it rewrites the `allow`/`deny` lists of the
13246
+ * `tools.<name>` blocks it manages and the `paths` lists, leaving each tool's
13247
+ * `disabled` flag, unmanaged tools and every other key in place. `hooks` is
13248
+ * owned as a whole key because the hooks writer emits every event list.
13249
+ * `subagents` is owned as a whole key too: its writer recomputes the block
13250
+ * from the existing file (`default` and the built-in `general` agent carried
13251
+ * over, every other agent replaced) before applying the patch.
13044
13252
  */
13045
13253
  const POOL_SETTINGS_DECLARATION = {
13046
13254
  format: "yaml",
13047
13255
  invalidRootPolicy: "error",
13048
- features: { mcp: {
13049
- kind: "replace-owned-keys",
13050
- ownedKeys: ["mcp_servers"]
13051
- } }
13256
+ features: {
13257
+ mcp: {
13258
+ kind: "replace-owned-keys",
13259
+ ownedKeys: ["mcp_servers"]
13260
+ },
13261
+ permissions: { kind: "deep-merge" },
13262
+ hooks: {
13263
+ kind: "replace-owned-keys",
13264
+ ownedKeys: ["hooks"]
13265
+ },
13266
+ subagents: {
13267
+ kind: "replace-owned-keys",
13268
+ ownedKeys: ["subagents"]
13269
+ }
13270
+ }
13052
13271
  };
13053
13272
  /**
13054
13273
  * ZCode's settings file, which also carries model/theme/permission keys
@@ -20443,6 +20662,11 @@ var CommandsProcessor = class extends FeatureProcessor {
20443
20662
  global: this.global,
20444
20663
  relativeFilePath: HERMESAGENT_CONFIG_FILE_PATH
20445
20664
  }));
20665
+ if (await refusesWriteOutsideRoot({
20666
+ logger: this.logger,
20667
+ rootPath: this.outputRoot,
20668
+ targetPath: configPath
20669
+ })) return changedCount;
20446
20670
  const currentContent = await readFileContentOrNull(configPath);
20447
20671
  if (currentContent === null) return changedCount;
20448
20672
  const nextContent = getDisabledHermesCommandsPluginConfigContent({
@@ -20465,6 +20689,11 @@ var CommandsProcessor = class extends FeatureProcessor {
20465
20689
  if (this.toolTarget !== "goose" || !this.global) return 0;
20466
20690
  if (generatedFiles.some((file) => file instanceof GooseCommand)) return 0;
20467
20691
  const configPath = (0, node_path.join)(this.outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME);
20692
+ if (await refusesWriteOutsideRoot({
20693
+ logger: this.logger,
20694
+ rootPath: this.outputRoot,
20695
+ targetPath: configPath
20696
+ })) return 0;
20468
20697
  const currentContent = await readFileContentOrNull(configPath);
20469
20698
  if (currentContent === null || !hasManagedGooseSlashCommands(currentContent)) return 0;
20470
20699
  const nextContent = getGooseSlashCommandsConfigContent({
@@ -27564,6 +27793,302 @@ var PiHooks = class PiHooks extends ToolHooks {
27564
27793
  }
27565
27794
  };
27566
27795
  //#endregion
27796
+ //#region src/constants/pool-paths.ts
27797
+ /**
27798
+ * Pool (Poolside's coding agent CLI) configuration-layout conventions.
27799
+ *
27800
+ * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
27801
+ * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
27802
+ * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
27803
+ * path), the project-root `AGENTS.md`, and nested per-directory
27804
+ * `AGENTS.md` files from the repository root down through the working
27805
+ * directory, deeper files taking precedence. It skips ignored directories
27806
+ * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
27807
+ *
27808
+ * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
27809
+ * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
27810
+ * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
27811
+ * the skill directories of other Agent Skills tools; rulesync writes only the
27812
+ * Pool-specific roots and leaves the shared ones to their own targets.
27813
+ *
27814
+ * @see https://docs.poolside.ai/agent-instructions
27815
+ * @see https://docs.poolside.ai/skills
27816
+ * @see https://github.com/poolsideai/pool
27817
+ */
27818
+ /** Project-scoped `.poolside/` directory at the project root. */
27819
+ const POOL_DIR = ".poolside";
27820
+ /** Global config directory for Pool, relative to the home directory. */
27821
+ const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
27822
+ /** Project skills root, relative to the project root. */
27823
+ const POOL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_DIR, "skills");
27824
+ /** Global skills root, relative to the home directory. */
27825
+ const POOL_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_GLOBAL_DIR, "skills");
27826
+ /**
27827
+ * Pool's settings file name. MCP servers live under the top-level
27828
+ * `mcp_servers` key of `.poolside/settings.yaml` (project, committed) and
27829
+ * `~/.config/poolside/settings.yaml` (global); the untracked
27830
+ * `.poolside/settings.local.yaml` overlay is left to the user.
27831
+ *
27832
+ * @see https://docs.poolside.ai/mcp-servers
27833
+ * @see https://docs.poolside.ai/settings-file-reference
27834
+ */
27835
+ const POOL_SETTINGS_FILE_NAME = "settings.yaml";
27836
+ /** Pool's untracked per-user overlay next to the settings file; never written. */
27837
+ const POOL_SETTINGS_LOCAL_FILE_NAME = "settings.local.yaml";
27838
+ /** Top-level key of Pool's settings file that holds the MCP server map. */
27839
+ const POOL_MCP_SERVERS_KEY = "mcp_servers";
27840
+ /** Top-level key of Pool's settings file that holds per-tool permission rules. */
27841
+ const POOL_TOOLS_KEY = "tools";
27842
+ /** Top-level key of Pool's settings file that holds file access rules. */
27843
+ const POOL_PATHS_KEY = "paths";
27844
+ /** Top-level key of Pool's settings file that holds the per-event hook lists. */
27845
+ const POOL_HOOKS_KEY = "hooks";
27846
+ /**
27847
+ * Top-level key of Pool's settings file that holds the subagent registry:
27848
+ * `subagents.default` names the agent delegated to by default and
27849
+ * `subagents.agents.<name>` defines each agent.
27850
+ *
27851
+ * @see https://docs.poolside.ai/subagents
27852
+ */
27853
+ const POOL_SUBAGENTS_KEY = "subagents";
27854
+ /** Key under `subagents` that holds the per-agent definitions. */
27855
+ const POOL_SUBAGENTS_AGENTS_KEY = "agents";
27856
+ /** Pool's reserved built-in agent, always `in_process` and never generated. */
27857
+ const POOL_GENERAL_AGENT_NAME = "general";
27858
+ //#endregion
27859
+ //#region src/features/hooks/pool-hooks.ts
27860
+ /** The matcher Pool reads as "any tool"; `""` means the same. */
27861
+ const POOL_MATCH_ALL = "*";
27862
+ const SUPPORTED_POOL_EVENTS = new Set(POOL_HOOK_EVENTS);
27863
+ /**
27864
+ * The Pool events that test `matcher` against a tool name. Pool ignores the
27865
+ * field on every other event (it stays required by the schema), so an
27866
+ * authored matcher there is dropped with a warning and `*` written instead.
27867
+ */
27868
+ const POOL_MATCHER_EVENTS = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUse"]);
27869
+ /**
27870
+ * Single spelling of the settings.yaml codec/policy, matching PoolMcp and
27871
+ * PoolPermissions: fail closed on an unparseable root rather than replacing
27872
+ * the user's primary Pool settings with generated output.
27873
+ */
27874
+ function parsePoolSettings$3(fileContent, filePath) {
27875
+ return parseSharedConfig({
27876
+ format: "yaml",
27877
+ fileContent,
27878
+ filePath,
27879
+ invalidRootPolicy: "error"
27880
+ });
27881
+ }
27882
+ /**
27883
+ * Build the `hooks` event lists of Pool's settings file from a canonical hooks
27884
+ * config. Pool keys a flat list of `{name, matcher, command, timeout}` entries
27885
+ * by event name. Only `type: "command"` canonical hooks are emitted, since a
27886
+ * Pool hook is a shell command. A shared canonical event Pool does not fire is
27887
+ * skipped (the HooksProcessor reports it), while an event under the
27888
+ * `pool.hooks` override — such as one an import filed there — is written
27889
+ * verbatim so it round-trips.
27890
+ */
27891
+ function canonicalToPoolHooks({ config, toolOverride, logger }) {
27892
+ const sharedHooks = {};
27893
+ for (const [event, defs] of Object.entries(config.hooks)) if (SUPPORTED_POOL_EVENTS.has(event)) sharedHooks[event] = defs;
27894
+ const effective = {
27895
+ ...sharedHooks,
27896
+ ...toolOverride
27897
+ };
27898
+ const hooks = {};
27899
+ for (const [event, defs] of Object.entries(effective)) {
27900
+ if (isPrototypePollutionKey(event)) continue;
27901
+ const poolEvent = lookupOwn({
27902
+ record: CANONICAL_TO_POOL_EVENT_NAMES,
27903
+ key: event
27904
+ }) ?? event;
27905
+ const entries = [];
27906
+ for (const def of defs) {
27907
+ const entry = canonicalDefToPoolEntry({
27908
+ def,
27909
+ event,
27910
+ poolEvent,
27911
+ logger
27912
+ });
27913
+ if (entry !== null) entries.push(entry);
27914
+ }
27915
+ if (entries.length > 0) hooks[poolEvent] = entries;
27916
+ }
27917
+ return hooks;
27918
+ }
27919
+ /** Convert one canonical hook definition to a Pool entry, or null to skip. */
27920
+ function canonicalDefToPoolEntry({ def, event, poolEvent, logger }) {
27921
+ if ((def.type ?? "command") !== "command") return null;
27922
+ if (typeof def.command !== "string" || def.command === "") {
27923
+ logger?.warn(`Pool hook ${quoteValueForWarning(def.name ?? "")} under "${event}" has no "command", which Pool would reject, so it was skipped.`);
27924
+ return null;
27925
+ }
27926
+ const authoredMatcher = typeof def.matcher === "string" && def.matcher !== "" ? def.matcher : POOL_MATCH_ALL;
27927
+ let matcher = authoredMatcher;
27928
+ if (!POOL_MATCHER_EVENTS.has(poolEvent) && authoredMatcher !== POOL_MATCH_ALL) {
27929
+ logger?.warn(`Pool ignores "matcher" on "${poolEvent}", so the matcher ${quoteValueForWarning(authoredMatcher)} of the hook ${quoteValueForWarning(def.name ?? def.command)} under "${event}" was dropped.`);
27930
+ matcher = POOL_MATCH_ALL;
27931
+ }
27932
+ const entry = {
27933
+ ...typeof def.name === "string" && def.name !== "" ? { name: def.name } : {},
27934
+ matcher,
27935
+ command: def.command
27936
+ };
27937
+ if (typeof def.timeout === "number") entry.timeout = Math.ceil(def.timeout);
27938
+ return entry;
27939
+ }
27940
+ /** Convert one raw hook entry to a canonical definition, or null to skip. */
27941
+ function poolEntryToCanonicalDef({ raw, poolEvent }) {
27942
+ if (!isRecord$1(raw) || typeof raw.command !== "string" || raw.command === "") return null;
27943
+ const def = {
27944
+ type: "command",
27945
+ command: raw.command
27946
+ };
27947
+ if (typeof raw.name === "string" && raw.name !== "") def.name = raw.name;
27948
+ if (POOL_MATCHER_EVENTS.has(poolEvent) && typeof raw.matcher === "string" && raw.matcher !== "" && raw.matcher !== POOL_MATCH_ALL) def.matcher = raw.matcher;
27949
+ if (typeof raw.timeout === "number") def.timeout = raw.timeout;
27950
+ return def;
27951
+ }
27952
+ /**
27953
+ * Reverse {@link canonicalToPoolHooks}: parse the event lists of the `hooks`
27954
+ * block back into a canonical event → definition[] record. An event Pool does
27955
+ * not document is carried under its own name so `buildImportedHooksConfig`
27956
+ * files it under the `pool.hooks` override; a non-list sibling such as
27957
+ * `stop_hook_max_continuations` is not a hook and is left alone.
27958
+ */
27959
+ function poolHooksToCanonical(hooksBlock) {
27960
+ const canonical = {};
27961
+ if (!isRecord$1(hooksBlock)) return canonical;
27962
+ for (const [poolEvent, rawEntries] of Object.entries(hooksBlock)) {
27963
+ if (isPrototypePollutionKey(poolEvent) || !Array.isArray(rawEntries)) continue;
27964
+ const canonicalEvent = lookupOwn({
27965
+ record: POOL_TO_CANONICAL_EVENT_NAMES,
27966
+ key: poolEvent
27967
+ }) ?? poolEvent;
27968
+ const defs = rawEntries.map((raw) => poolEntryToCanonicalDef({
27969
+ raw,
27970
+ poolEvent
27971
+ })).filter((def) => def !== null);
27972
+ if (defs.length === 0) continue;
27973
+ canonical[canonicalEvent] = [...lookupOwn({
27974
+ record: canonical,
27975
+ key: canonicalEvent
27976
+ }) ?? [], ...defs];
27977
+ }
27978
+ return canonical;
27979
+ }
27980
+ /**
27981
+ * The keys of an existing `hooks` block that are not event lists —
27982
+ * `stop_hook_max_continuations` and anything else Pool may add beside the
27983
+ * events — which survive a regenerate untouched.
27984
+ */
27985
+ function nonEventHookKeys(hooksBlock) {
27986
+ const kept = {};
27987
+ if (!isRecord$1(hooksBlock)) return kept;
27988
+ for (const [key, value] of Object.entries(hooksBlock)) if (!isPrototypePollutionKey(key) && !Array.isArray(value)) kept[key] = value;
27989
+ return kept;
27990
+ }
27991
+ /**
27992
+ * Pool hooks.
27993
+ *
27994
+ * Pool reads hooks from the `hooks` key of its settings file —
27995
+ * `.poolside/settings.yaml` at project scope and
27996
+ * `~/.config/poolside/settings.yaml` at user scope — as
27997
+ * `hooks.<Event>: [{name?, matcher, command, timeout?}]` for `PreToolUse`,
27998
+ * `PostToolUse`, `UserPromptSubmit`, `PreCompact`, `SessionStart` and `Stop`.
27999
+ * The event lists are rewritten as a whole; the block's non-list sibling
28000
+ * `stop_hook_max_continuations` and every other top-level key of the file
28001
+ * (`mcp_servers`, `tools`, `paths`, `pool`, ...) are preserved, and the file
28002
+ * is never deleted.
28003
+ *
28004
+ * @see https://docs.poolside.ai/hooks
28005
+ * @see https://docs.poolside.ai/settings-file-reference
28006
+ */
28007
+ var PoolHooks = class PoolHooks extends ToolHooks {
28008
+ settings;
28009
+ constructor(params) {
28010
+ super(params);
28011
+ this.settings = parsePoolSettings$3(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
28012
+ }
28013
+ getSettings() {
28014
+ return this.settings;
28015
+ }
28016
+ isDeletable() {
28017
+ return false;
28018
+ }
28019
+ static getSettablePaths({ global = false } = {}) {
28020
+ return {
28021
+ relativeDirPath: global ? POOL_GLOBAL_DIR : POOL_DIR,
28022
+ relativeFilePath: POOL_SETTINGS_FILE_NAME
28023
+ };
28024
+ }
28025
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
28026
+ const paths = this.getSettablePaths({ global });
28027
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "";
28028
+ return new PoolHooks({
28029
+ outputRoot,
28030
+ relativeDirPath: paths.relativeDirPath,
28031
+ relativeFilePath: paths.relativeFilePath,
28032
+ fileContent,
28033
+ validate,
28034
+ global
28035
+ });
28036
+ }
28037
+ static async fromRulesyncHooks({ outputRoot = process.cwd(), rulesyncHooks, validate = true, global = false, logger }) {
28038
+ const paths = this.getSettablePaths({ global });
28039
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
28040
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
28041
+ const existing = parsePoolSettings$3(existingContent, filePath);
28042
+ const config = rulesyncHooks.getJson();
28043
+ const hooks = canonicalToPoolHooks({
28044
+ config,
28045
+ toolOverride: config.pool?.hooks,
28046
+ logger
28047
+ });
28048
+ return new PoolHooks({
28049
+ outputRoot,
28050
+ relativeDirPath: paths.relativeDirPath,
28051
+ relativeFilePath: paths.relativeFilePath,
28052
+ fileContent: applySharedConfigPatch({
28053
+ fileKey: sharedConfigFileKey(paths),
28054
+ feature: "hooks",
28055
+ existingContent,
28056
+ patch: { [POOL_HOOKS_KEY]: {
28057
+ ...nonEventHookKeys(existing[POOL_HOOKS_KEY]),
28058
+ ...hooks
28059
+ } },
28060
+ filePath,
28061
+ logger
28062
+ }),
28063
+ validate,
28064
+ global
28065
+ });
28066
+ }
28067
+ toRulesyncHooks() {
28068
+ const hooks = poolHooksToCanonical(this.settings[POOL_HOOKS_KEY]);
28069
+ return this.toRulesyncHooksDefault({ fileContent: JSON.stringify(buildImportedHooksConfig({
28070
+ hooks,
28071
+ overrideKey: "pool"
28072
+ }), null, 2) });
28073
+ }
28074
+ validate() {
28075
+ return {
28076
+ success: true,
28077
+ error: null
28078
+ };
28079
+ }
28080
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
28081
+ return new PoolHooks({
28082
+ outputRoot,
28083
+ relativeDirPath,
28084
+ relativeFilePath,
28085
+ fileContent: "",
28086
+ validate: false,
28087
+ global
28088
+ });
28089
+ }
28090
+ };
28091
+ //#endregion
27567
28092
  //#region src/features/hooks/qwencode-hooks.ts
27568
28093
  /**
27569
28094
  * Build a single Qwen Code hook object from a canonical hook definition.
@@ -28677,14 +29202,15 @@ const HooksProcessorToolTargetSchema = zod_mini.z.enum(hooksProcessorToolTargetT
28677
29202
  /**
28678
29203
  * Event names present in the config that the target's adapter cannot emit.
28679
29204
  *
28680
- * When the factory passes override-block keys through verbatim
28681
- * (`passthroughOverrideEvents`), those keys are excluded from the check so
28682
- * documented passthrough triggers aren't falsely reported as skipped.
29205
+ * Shared (`hooks`) keys are always checked. Override-block keys are only
29206
+ * checked when the factory declares `dropsUnknownOverrideEvents`; otherwise
29207
+ * the adapter either emits them verbatim or reports its own drops, and a
29208
+ * generic "not supported" warning would be wrong or duplicated.
28683
29209
  */
28684
29210
  function unsupportedEventNames(params) {
28685
29211
  const { factory, sharedHooks, effectiveHooks } = params;
28686
29212
  const supportedEvents = new Set(factory.supportedEvents);
28687
- const eventNames = factory.passthroughOverrideEvents ? Object.keys(sharedHooks) : Object.keys(effectiveHooks);
29213
+ const eventNames = factory.dropsUnknownOverrideEvents ? Object.keys(effectiveHooks) : Object.keys(sharedHooks);
28688
29214
  return [...new Set(eventNames)].filter((e) => !supportedEvents.has(e));
28689
29215
  }
28690
29216
  /**
@@ -28777,7 +29303,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28777
29303
  supportedEvents: AMP_HOOK_EVENTS,
28778
29304
  supportedHookTypes: ["command"],
28779
29305
  supportsMatcher: true,
28780
- matcherEvents: ["preToolUse", "postToolUse"]
29306
+ matcherEvents: ["preToolUse", "postToolUse"],
29307
+ dropsUnknownOverrideEvents: true
28781
29308
  }],
28782
29309
  ["antigravity-cli", {
28783
29310
  class: AntigravityCliHooks,
@@ -28810,7 +29337,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28810
29337
  },
28811
29338
  supportedEvents: ANTIGRAVITY_HOOK_EVENTS,
28812
29339
  supportedHookTypes: ["command"],
28813
- supportsMatcher: true
29340
+ supportsMatcher: true,
29341
+ dropsUnknownOverrideEvents: true
28814
29342
  }],
28815
29343
  ["cursor", {
28816
29344
  class: CursorHooks,
@@ -28855,7 +29383,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28855
29383
  "mcp_tool",
28856
29384
  "agent"
28857
29385
  ],
28858
- supportsMatcher: true
29386
+ supportsMatcher: true,
29387
+ dropsUnknownOverrideEvents: true
28859
29388
  }],
28860
29389
  ["codexcli", {
28861
29390
  class: CodexcliHooks,
@@ -28916,7 +29445,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28916
29445
  supportedEvents: KILO_HOOK_EVENTS,
28917
29446
  supportedHookTypes: ["command"],
28918
29447
  supportsMatcher: true,
28919
- matcherEvents: ["preToolUse", "postToolUse"]
29448
+ matcherEvents: ["preToolUse", "postToolUse"],
29449
+ dropsUnknownOverrideEvents: true
28920
29450
  }],
28921
29451
  ["opencode", {
28922
29452
  class: OpencodeHooks,
@@ -28928,7 +29458,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28928
29458
  supportedEvents: OPENCODE_HOOK_EVENTS,
28929
29459
  supportedHookTypes: ["command"],
28930
29460
  supportsMatcher: true,
28931
- matcherEvents: ["preToolUse", "postToolUse"]
29461
+ matcherEvents: ["preToolUse", "postToolUse"],
29462
+ dropsUnknownOverrideEvents: true
28932
29463
  }],
28933
29464
  ["pi", {
28934
29465
  class: PiHooks,
@@ -28939,7 +29470,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28939
29470
  },
28940
29471
  supportedEvents: PI_HOOK_EVENTS,
28941
29472
  supportedHookTypes: ["command"],
28942
- supportsMatcher: true
29473
+ supportsMatcher: true,
29474
+ dropsUnknownOverrideEvents: true
28943
29475
  }],
28944
29476
  ["factorydroid", {
28945
29477
  class: FactorydroidHooks,
@@ -28961,7 +29493,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28961
29493
  },
28962
29494
  supportedEvents: CLINE_HOOK_EVENTS,
28963
29495
  supportedHookTypes: ["command"],
28964
- supportsMatcher: false
29496
+ supportsMatcher: false,
29497
+ dropsUnknownOverrideEvents: true
28965
29498
  }],
28966
29499
  ["goose", {
28967
29500
  class: GooseHooks,
@@ -28983,8 +29516,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28983
29516
  },
28984
29517
  supportedEvents: HERMESAGENT_HOOK_EVENTS,
28985
29518
  supportedHookTypes: ["command"],
28986
- supportsMatcher: true,
28987
- passthroughOverrideEvents: true
29519
+ supportsMatcher: true
28988
29520
  }],
28989
29521
  ["kimi-code", {
28990
29522
  class: KimiCodeHooks,
@@ -28995,8 +29527,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
28995
29527
  },
28996
29528
  supportedEvents: KIMI_CODE_HOOK_EVENTS,
28997
29529
  supportedHookTypes: ["command"],
28998
- supportsMatcher: true,
28999
- passthroughOverrideEvents: true
29530
+ supportsMatcher: true
29000
29531
  }],
29001
29532
  ["deepagents", {
29002
29533
  class: DeepagentsHooks,
@@ -29007,7 +29538,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29007
29538
  },
29008
29539
  supportedEvents: DEEPAGENTS_HOOK_EVENTS,
29009
29540
  supportedHookTypes: ["command"],
29010
- supportsMatcher: true
29541
+ supportsMatcher: true,
29542
+ dropsUnknownOverrideEvents: true
29011
29543
  }],
29012
29544
  ["kiro", {
29013
29545
  class: KiroHooks,
@@ -29018,8 +29550,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29018
29550
  },
29019
29551
  supportedEvents: KIRO_HOOK_EVENTS,
29020
29552
  supportedHookTypes: ["command"],
29021
- supportsMatcher: true,
29022
- passthroughOverrideEvents: true
29553
+ supportsMatcher: true
29023
29554
  }],
29024
29555
  ["kiro-cli", {
29025
29556
  class: KiroCliHooks,
@@ -29030,8 +29561,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29030
29561
  },
29031
29562
  supportedEvents: KIRO_IDE_HOOK_EVENTS,
29032
29563
  supportedHookTypes: ["command", "prompt"],
29033
- supportsMatcher: true,
29034
- passthroughOverrideEvents: true
29564
+ supportsMatcher: true
29035
29565
  }],
29036
29566
  ["kiro-ide", {
29037
29567
  class: KiroIdeHooks,
@@ -29042,8 +29572,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29042
29572
  },
29043
29573
  supportedEvents: KIRO_IDE_HOOK_EVENTS,
29044
29574
  supportedHookTypes: ["command", "prompt"],
29045
- supportsMatcher: true,
29046
- passthroughOverrideEvents: true
29575
+ supportsMatcher: true
29047
29576
  }],
29048
29577
  ["devin", {
29049
29578
  class: DevinHooks,
@@ -29161,6 +29690,18 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29161
29690
  },
29162
29691
  supportedEvents: VIBE_HOOK_EVENTS,
29163
29692
  supportedHookTypes: ["command"],
29693
+ supportsMatcher: true,
29694
+ dropsUnknownOverrideEvents: true
29695
+ }],
29696
+ ["pool", {
29697
+ class: PoolHooks,
29698
+ meta: {
29699
+ supportsProject: true,
29700
+ supportsGlobal: true,
29701
+ supportsImport: true
29702
+ },
29703
+ supportedEvents: POOL_HOOK_EVENTS,
29704
+ supportedHookTypes: ["command"],
29164
29705
  supportsMatcher: true
29165
29706
  }],
29166
29707
  ["qwencode", {
@@ -29188,7 +29729,8 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29188
29729
  },
29189
29730
  supportedEvents: REASONIX_HOOK_EVENTS,
29190
29731
  supportedHookTypes: ["command"],
29191
- supportsMatcher: true
29732
+ supportsMatcher: true,
29733
+ dropsUnknownOverrideEvents: true
29192
29734
  }],
29193
29735
  ["grokcli", {
29194
29736
  class: GrokcliHooks,
@@ -29210,8 +29752,7 @@ const toolHooksFactories = /* @__PURE__ */ new Map([
29210
29752
  },
29211
29753
  supportedEvents: CRUSH_HOOK_EVENTS,
29212
29754
  supportedHookTypes: ["command"],
29213
- supportsMatcher: true,
29214
- passthroughOverrideEvents: true
29755
+ supportsMatcher: true
29215
29756
  }],
29216
29757
  ["zcode", {
29217
29758
  class: ZcodeHooks,
@@ -37142,49 +37683,6 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
37142
37683
  }
37143
37684
  };
37144
37685
  //#endregion
37145
- //#region src/constants/pool-paths.ts
37146
- /**
37147
- * Pool (Poolside's coding agent CLI) configuration-layout conventions.
37148
- *
37149
- * Pool reads `AGENTS.md` instruction files the same way the AGENTS.md standard
37150
- * describes them: the personal `~/.config/poolside/AGENTS.md` (Pool itself
37151
- * honours `XDG_CONFIG_HOME` upstream; rulesync writes only the XDG-default
37152
- * path), the project-root `AGENTS.md`, and nested per-directory
37153
- * `AGENTS.md` files from the repository root down through the working
37154
- * directory, deeper files taking precedence. It skips ignored directories
37155
- * (`.git/`, `node_modules/`, cache directories, repository ignore rules).
37156
- *
37157
- * Skills follow the Agent Skills format (`<name>/SKILL.md` bundles). Pool
37158
- * scans `.poolside/skills/` (project) and `~/.config/poolside/skills/`
37159
- * (global) plus the shared `.agents/skills/` / `~/.agents/skills/` roots and
37160
- * the skill directories of other Agent Skills tools; rulesync writes only the
37161
- * Pool-specific roots and leaves the shared ones to their own targets.
37162
- *
37163
- * @see https://docs.poolside.ai/agent-instructions
37164
- * @see https://docs.poolside.ai/skills
37165
- * @see https://github.com/poolsideai/pool
37166
- */
37167
- /** Project-scoped `.poolside/` directory at the project root. */
37168
- const POOL_DIR = ".poolside";
37169
- /** Global config directory for Pool, relative to the home directory. */
37170
- const POOL_GLOBAL_DIR = (0, node_path.join)(".config", "poolside");
37171
- /** Project skills root, relative to the project root. */
37172
- const POOL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_DIR, "skills");
37173
- /** Global skills root, relative to the home directory. */
37174
- const POOL_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(POOL_GLOBAL_DIR, "skills");
37175
- /**
37176
- * Pool's settings file name. MCP servers live under the top-level
37177
- * `mcp_servers` key of `.poolside/settings.yaml` (project, committed) and
37178
- * `~/.config/poolside/settings.yaml` (global); the untracked
37179
- * `.poolside/settings.local.yaml` overlay is left to the user.
37180
- *
37181
- * @see https://docs.poolside.ai/mcp-servers
37182
- * @see https://docs.poolside.ai/settings-file-reference
37183
- */
37184
- const POOL_SETTINGS_FILE_NAME = "settings.yaml";
37185
- /** Top-level key of Pool's settings file that holds the MCP server map. */
37186
- const POOL_MCP_SERVERS_KEY = "mcp_servers";
37187
- //#endregion
37188
37686
  //#region src/features/mcp/pool-mcp.ts
37189
37687
  /**
37190
37688
  * Single spelling of the settings.yaml codec/policy, matching the
@@ -37192,7 +37690,7 @@ const POOL_MCP_SERVERS_KEY = "mcp_servers";
37192
37690
  * unparseable root rather than replacing the user's primary Pool settings with
37193
37691
  * generated output.
37194
37692
  */
37195
- function parsePoolSettings(fileContent, filePath) {
37693
+ function parsePoolSettings$2(fileContent, filePath) {
37196
37694
  return parseSharedConfig({
37197
37695
  format: "yaml",
37198
37696
  fileContent,
@@ -37424,7 +37922,7 @@ var PoolMcp = class PoolMcp extends ToolMcp {
37424
37922
  settings;
37425
37923
  constructor(params) {
37426
37924
  super(params);
37427
- this.settings = parsePoolSettings(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
37925
+ this.settings = parsePoolSettings$2(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
37428
37926
  }
37429
37927
  getSettings() {
37430
37928
  return this.settings;
@@ -41268,7 +41766,7 @@ const ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES = {
41268
41766
  function toAntigravityCliToolName(canonical) {
41269
41767
  return CANONICAL_TO_ANTIGRAVITY_CLI_TOOL_NAMES[canonical] ?? canonical;
41270
41768
  }
41271
- function toCanonicalToolName$8(cliName) {
41769
+ function toCanonicalToolName$9(cliName) {
41272
41770
  return ANTIGRAVITY_CLI_TO_CANONICAL_TOOL_NAMES[cliName] ?? cliName;
41273
41771
  }
41274
41772
  /**
@@ -41484,7 +41982,7 @@ function convertAntigravityCliToRulesyncPermissions(params) {
41484
41982
  const processEntries = (entries, action) => {
41485
41983
  for (const entry of entries) {
41486
41984
  const { toolName, pattern } = parsePermissionEntry$1(entry);
41487
- const canonical = toCanonicalToolName$8(toolName);
41985
+ const canonical = toCanonicalToolName$9(toolName);
41488
41986
  if (!permission[canonical]) permission[canonical] = {};
41489
41987
  permission[canonical][pattern] = action;
41490
41988
  }
@@ -41763,7 +42261,7 @@ function toLegacyAugmentToolName(augmentName) {
41763
42261
  key: augmentName
41764
42262
  }) ?? augmentName;
41765
42263
  }
41766
- function toCanonicalToolName$7(augmentName) {
42264
+ function toCanonicalToolName$8(augmentName) {
41767
42265
  const legacyName = toLegacyAugmentToolName(augmentName);
41768
42266
  return lookupOwn({
41769
42267
  record: AUGMENT_TO_CANONICAL_TOOL_NAMES,
@@ -42216,7 +42714,7 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
42216
42714
  const type = entry.permission.type;
42217
42715
  if (!isBasicAugmentType(type)) continue;
42218
42716
  const legacyToolName = toLegacyAugmentToolName(entry.toolName);
42219
- const canonical = toCanonicalToolName$7(entry.toolName);
42717
+ const canonical = toCanonicalToolName$8(entry.toolName);
42220
42718
  if (forbiddenMapKeys.has(canonical)) {
42221
42719
  logger?.warn(`AugmentCode permissions: skipping entry for tool '${entry.toolName}' because it maps to the reserved object key '${canonical}', which cannot be used as a permission key.`);
42222
42720
  continue;
@@ -42461,7 +42959,7 @@ const CLAUDE_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONIC
42461
42959
  function toClaudeToolName(canonical) {
42462
42960
  return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonical] ?? canonical;
42463
42961
  }
42464
- function toCanonicalToolName$6(claudeName) {
42962
+ function toCanonicalToolName$7(claudeName) {
42465
42963
  return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeName] ?? claudeName;
42466
42964
  }
42467
42965
  /**
@@ -43375,7 +43873,7 @@ function convertClaudeToRulesyncPermissions(params) {
43375
43873
  const processEntries = (entries, action) => {
43376
43874
  for (const entry of entries) {
43377
43875
  const { toolName, pattern } = parseClaudePermissionEntry(entry);
43378
- const canonical = toCanonicalToolName$6(toolName);
43876
+ const canonical = toCanonicalToolName$7(toolName);
43379
43877
  if (!permission[canonical]) permission[canonical] = {};
43380
43878
  permission[canonical][pattern] = action;
43381
43879
  }
@@ -44236,8 +44734,8 @@ function mapBashActionToDecision(action) {
44236
44734
  //#region src/features/permissions/commandcode-permissions.ts
44237
44735
  /** Top-level key of `.commandcode/settings.json` that rulesync owns. */
44238
44736
  const COMMANDCODE_PERMISSIONS_KEY = "permissions";
44239
- const CATCH_ALL_PATTERN$6 = "*";
44240
- const MCP_CANONICAL_PREFIX$5 = "mcp__";
44737
+ const CATCH_ALL_PATTERN$7 = "*";
44738
+ const MCP_CANONICAL_PREFIX$6 = "mcp__";
44241
44739
  const COMMANDCODE_ALL_MCP_RULE = "mcp__*";
44242
44740
  const CATEGORY_TO_COMMANDCODE_TOOL = {
44243
44741
  bash: "Shell",
@@ -44291,10 +44789,10 @@ const PADDED_SHELL_WILDCARD_REASON = "a pattern narrower than the bare 'Shell' (
44291
44789
  * restrictions and reach the `Shell` entries through `honorAllToolsOnBash`.
44292
44790
  */
44293
44791
  function buildCommandcodeRule(category, pattern) {
44294
- const catchAll = pattern === CATCH_ALL_PATTERN$6 || pattern === "";
44295
- if (category.startsWith(MCP_CANONICAL_PREFIX$5)) return catchAll ? category : `${category}(${pattern})`;
44296
- if (category === "mcp") return catchAll ? COMMANDCODE_ALL_MCP_RULE : `${MCP_CANONICAL_PREFIX$5}${pattern}`;
44297
- if (category === "*") return catchAll ? CATCH_ALL_PATTERN$6 : null;
44792
+ const catchAll = pattern === CATCH_ALL_PATTERN$7 || pattern === "";
44793
+ if (category.startsWith(MCP_CANONICAL_PREFIX$6)) return catchAll ? category : `${category}(${pattern})`;
44794
+ if (category === "mcp") return catchAll ? COMMANDCODE_ALL_MCP_RULE : `${MCP_CANONICAL_PREFIX$6}${pattern}`;
44795
+ if (category === "*") return catchAll ? CATCH_ALL_PATTERN$7 : null;
44298
44796
  const tool = Object.hasOwn(CATEGORY_TO_COMMANDCODE_TOOL, category) ? CATEGORY_TO_COMMANDCODE_TOOL[category] : void 0;
44299
44797
  if (tool === void 0) return null;
44300
44798
  return catchAll ? tool : `${tool}(${pattern})`;
@@ -44330,7 +44828,7 @@ function splitCommandcodeRule(rule) {
44330
44828
  }
44331
44829
  /** Whether a specifier means "no specifier" to Command Code: only `` and `*` do. */
44332
44830
  function isCatchAllSpecifier(inner) {
44333
- return inner === "" || inner === CATCH_ALL_PATTERN$6;
44831
+ return inner === "" || inner === CATCH_ALL_PATTERN$7;
44334
44832
  }
44335
44833
  /**
44336
44834
  * Read an MCP-shaped rule the way Command Code does (`command-code` 1.54.0,
@@ -44364,34 +44862,34 @@ function parseMcpCommandcodeRule({ tool, inner }) {
44364
44862
  if (server.length === 0) return null;
44365
44863
  const everyServer = {
44366
44864
  category: "mcp",
44367
- pattern: CATCH_ALL_PATTERN$6,
44865
+ pattern: CATCH_ALL_PATTERN$7,
44368
44866
  notInAllow: true
44369
44867
  };
44370
- if (tool.startsWith(MCP_CANONICAL_PREFIX$5)) {
44371
- if (server === CATCH_ALL_PATTERN$6) return everyServer;
44372
- if (server.includes(CATCH_ALL_PATTERN$6)) return null;
44868
+ if (tool.startsWith(MCP_CANONICAL_PREFIX$6)) {
44869
+ if (server === CATCH_ALL_PATTERN$7) return everyServer;
44870
+ if (server.includes(CATCH_ALL_PATTERN$7)) return null;
44373
44871
  return {
44374
- category: rest === "" || rest === CATCH_ALL_PATTERN$6 ? `${MCP_CANONICAL_PREFIX$5}${server}` : `${MCP_CANONICAL_PREFIX$5}${remainder}`,
44375
- pattern: CATCH_ALL_PATTERN$6,
44872
+ category: rest === "" || rest === CATCH_ALL_PATTERN$7 ? `${MCP_CANONICAL_PREFIX$6}${server}` : `${MCP_CANONICAL_PREFIX$6}${remainder}`,
44873
+ pattern: CATCH_ALL_PATTERN$7,
44376
44874
  notInAllow: false
44377
44875
  };
44378
44876
  }
44379
- const category = `${MCP_CANONICAL_PREFIX$5}${remainder}`;
44380
- const namesTool = separator !== -1 && rest.length > 0 && !remainder.includes(CATCH_ALL_PATTERN$6);
44877
+ const category = `${MCP_CANONICAL_PREFIX$6}${remainder}`;
44878
+ const namesTool = separator !== -1 && rest.length > 0 && !remainder.includes(CATCH_ALL_PATTERN$7);
44381
44879
  if (!isCatchAllSpecifier(inner)) return namesTool ? {
44382
44880
  category,
44383
44881
  pattern: inner,
44384
44882
  notInAllow: true
44385
44883
  } : null;
44386
- if (remainder === CATCH_ALL_PATTERN$6) return everyServer;
44387
- if (rest === CATCH_ALL_PATTERN$6 && !server.includes(CATCH_ALL_PATTERN$6)) return {
44388
- category: `${MCP_CANONICAL_PREFIX$5}${server}`,
44389
- pattern: CATCH_ALL_PATTERN$6,
44884
+ if (remainder === CATCH_ALL_PATTERN$7) return everyServer;
44885
+ if (rest === CATCH_ALL_PATTERN$7 && !server.includes(CATCH_ALL_PATTERN$7)) return {
44886
+ category: `${MCP_CANONICAL_PREFIX$6}${server}`,
44887
+ pattern: CATCH_ALL_PATTERN$7,
44390
44888
  notInAllow: true
44391
44889
  };
44392
44890
  return namesTool ? {
44393
44891
  category,
44394
- pattern: CATCH_ALL_PATTERN$6,
44892
+ pattern: CATCH_ALL_PATTERN$7,
44395
44893
  notInAllow: false
44396
44894
  } : null;
44397
44895
  }
@@ -44415,14 +44913,14 @@ function friendlyToolRule({ category, inner }) {
44415
44913
  if (category === "bash") {
44416
44914
  if (isCatchAllSpecifier(inner)) return {
44417
44915
  category,
44418
- pattern: CATCH_ALL_PATTERN$6,
44916
+ pattern: CATCH_ALL_PATTERN$7,
44419
44917
  notInAllow: false
44420
44918
  };
44421
44919
  const normalized = inner.trim().replace(/\s+/g, " ");
44422
44920
  if (normalized === "" || normalized === ":*") return null;
44423
44921
  return isCatchAllSpecifier(normalized) ? {
44424
44922
  category,
44425
- pattern: CATCH_ALL_PATTERN$6,
44923
+ pattern: CATCH_ALL_PATTERN$7,
44426
44924
  notInAllow: true
44427
44925
  } : {
44428
44926
  category,
@@ -44432,7 +44930,7 @@ function friendlyToolRule({ category, inner }) {
44432
44930
  }
44433
44931
  if (isCatchAllSpecifier(inner)) return {
44434
44932
  category,
44435
- pattern: CATCH_ALL_PATTERN$6,
44933
+ pattern: CATCH_ALL_PATTERN$7,
44436
44934
  notInAllow: false
44437
44935
  };
44438
44936
  return isCatchAllSpecifier(inner.trim()) ? null : {
@@ -44451,13 +44949,13 @@ function friendlyToolRule({ category, inner }) {
44451
44949
  */
44452
44950
  function parseCommandcodeRule(rule) {
44453
44951
  const { tool, inner } = splitCommandcodeRule(rule);
44454
- if (tool === CATCH_ALL_PATTERN$6) return isCatchAllSpecifier(inner) ? {
44952
+ if (tool === CATCH_ALL_PATTERN$7) return isCatchAllSpecifier(inner) ? {
44455
44953
  category: "*",
44456
- pattern: CATCH_ALL_PATTERN$6,
44954
+ pattern: CATCH_ALL_PATTERN$7,
44457
44955
  notInAllow: true
44458
44956
  } : null;
44459
44957
  const lowered = tool.toLowerCase();
44460
- if (lowered.startsWith(MCP_CANONICAL_PREFIX$5)) return parseMcpCommandcodeRule({
44958
+ if (lowered.startsWith(MCP_CANONICAL_PREFIX$6)) return parseMcpCommandcodeRule({
44461
44959
  tool,
44462
44960
  inner
44463
44961
  });
@@ -44475,12 +44973,12 @@ function parseCommandcodeRule(rule) {
44475
44973
  */
44476
44974
  function isFoldedMcpToolNameRule({ rule, category }) {
44477
44975
  const { tool } = splitCommandcodeRule(rule);
44478
- return category.startsWith(MCP_CANONICAL_PREFIX$5) && !tool.startsWith(MCP_CANONICAL_PREFIX$5);
44976
+ return category.startsWith(MCP_CANONICAL_PREFIX$6) && !tool.startsWith(MCP_CANONICAL_PREFIX$6);
44479
44977
  }
44480
44978
  /** The `Tool` half of `Tool(specifier)`, when the rule is an exact-prefix MCP rule with a specifier. */
44481
44979
  function scopedMcpRuleTool(rule) {
44482
44980
  const { tool, inner } = splitCommandcodeRule(rule);
44483
- return !isCatchAllSpecifier(inner) && tool.startsWith(MCP_CANONICAL_PREFIX$5) ? tool : null;
44981
+ return !isCatchAllSpecifier(inner) && tool.startsWith(MCP_CANONICAL_PREFIX$6) ? tool : null;
44484
44982
  }
44485
44983
  /**
44486
44984
  * The string entries of a Command Code permission list. Command Code reads
@@ -44523,7 +45021,7 @@ function preservedRules({ existingPermissions, key, managedCategories, written,
44523
45021
  }).filter((rule) => {
44524
45022
  const parsed = parseCommandcodeRule(rule);
44525
45023
  if (parsed === null || !managedCategories.has(parsed.category)) return true;
44526
- const replacement = written.get(`${parsed.category}(${parsed.pattern})`) ?? written.get(`${parsed.category}(${CATCH_ALL_PATTERN$6})`);
45024
+ const replacement = written.get(`${parsed.category}(${parsed.pattern})`) ?? written.get(`${parsed.category}(${CATCH_ALL_PATTERN$7})`);
44527
45025
  if (key !== "allow" && isStricterAction({
44528
45026
  action: key,
44529
45027
  existing: replacement
@@ -44580,7 +45078,7 @@ function buildCommandcodeRuleLists({ config, existingPermissions, logger }) {
44580
45078
  const ranked = /* @__PURE__ */ new Map();
44581
45079
  const managedCategories = /* @__PURE__ */ new Set();
44582
45080
  for (const [category, rules] of Object.entries(permission)) {
44583
- const categoryRule = buildCommandcodeRule(category, CATCH_ALL_PATTERN$6);
45081
+ const categoryRule = buildCommandcodeRule(category, CATCH_ALL_PATTERN$7);
44584
45082
  const managedCategory = categoryRule === null ? null : parseCommandcodeRule(categoryRule);
44585
45083
  if (managedCategory !== null) managedCategories.add(managedCategory.category);
44586
45084
  for (const [pattern, action] of Object.entries(rules)) {
@@ -44649,7 +45147,7 @@ function buildCommandcodeRuleLists({ config, existingPermissions, logger }) {
44649
45147
  function skippedAllowImportMessage({ rule, category }) {
44650
45148
  const { tool, inner } = splitCommandcodeRule(rule);
44651
45149
  if (category === "bash") return `Command Code reads '${rule}' in "allow" as ${PADDED_SHELL_WILDCARD_REASON}, which rulesync cannot import without widening the grant to every command, so it was not imported.`;
44652
- if (tool.startsWith(MCP_CANONICAL_PREFIX$5) || isCatchAllSpecifier(inner)) return `Command Code ignores '${rule}' in "allow" (an allow rule must name what it grants), so it was not imported.`;
45150
+ if (tool.startsWith(MCP_CANONICAL_PREFIX$6) || isCatchAllSpecifier(inner)) return `Command Code ignores '${rule}' in "allow" (an allow rule must name what it grants), so it was not imported.`;
44653
45151
  return `Command Code globs the specifier of '${rule}' in "allow" against the call's arguments, which rulesync cannot import without widening the grant to the whole tool, so it was not imported.`;
44654
45152
  }
44655
45153
  /**
@@ -44851,7 +45349,7 @@ function parseCommandcodeSettings({ fileContent, relativePath }) {
44851
45349
  //#endregion
44852
45350
  //#region src/features/permissions/continue-permissions.ts
44853
45351
  const CONTINUE_GLOBAL_ONLY_MESSAGE = "Continue permissions are global-only; use --global to sync ~/.continue/permissions.yaml";
44854
- const CATCH_ALL_PATTERN$5 = "*";
45352
+ const CATCH_ALL_PATTERN$6 = "*";
44855
45353
  /**
44856
45354
  * A run of `*` (`**`, `***`) is the catch-all spelled another way: Continue
44857
45355
  * would match `Tool(**)` against every argument just as the bare tool name
@@ -44859,7 +45357,7 @@ const CATCH_ALL_PATTERN$5 = "*";
44859
45357
  * the bare entry instead of being skipped as a pattern-specific rule.
44860
45358
  */
44861
45359
  function normalizeCatchAllPattern$1(pattern) {
44862
- return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
45360
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$6 : pattern;
44863
45361
  }
44864
45362
  const CANONICAL_TO_CONTINUE_TOOL_NAMES = {
44865
45363
  bash: "Bash",
@@ -44888,7 +45386,7 @@ const CONTINUE_PATTERN_RE = /^([^(]+)(?:\(([^)]*)\))?$/;
44888
45386
  function toContinueToolName(canonical) {
44889
45387
  return Object.hasOwn(CANONICAL_TO_CONTINUE_TOOL_NAMES, canonical) ? CANONICAL_TO_CONTINUE_TOOL_NAMES[canonical] ?? canonical : canonical;
44890
45388
  }
44891
- function toCanonicalToolName$5(continueName) {
45389
+ function toCanonicalToolName$6(continueName) {
44892
45390
  return Object.hasOwn(CONTINUE_TO_CANONICAL_TOOL_NAMES, continueName) ? CONTINUE_TO_CANONICAL_TOOL_NAMES[continueName] ?? continueName : continueName;
44893
45391
  }
44894
45392
  /**
@@ -44896,7 +45394,7 @@ function toCanonicalToolName$5(continueName) {
44896
45394
  * `Tool(pattern)`.
44897
45395
  */
44898
45396
  function buildContinuePermissionEntry(toolName, pattern) {
44899
- return pattern === CATCH_ALL_PATTERN$5 ? toolName : `${toolName}(${pattern})`;
45397
+ return pattern === CATCH_ALL_PATTERN$6 ? toolName : `${toolName}(${pattern})`;
44900
45398
  }
44901
45399
  /**
44902
45400
  * Split a permissions.yaml entry into its tool name and argument pattern, or
@@ -44910,7 +45408,7 @@ function parseContinuePermissionEntry(entry) {
44910
45408
  if (toolName === "") return;
44911
45409
  return {
44912
45410
  toolName,
44913
- pattern: pattern === "" ? CATCH_ALL_PATTERN$5 : pattern
45411
+ pattern: pattern === "" ? CATCH_ALL_PATTERN$6 : pattern
44914
45412
  };
44915
45413
  }
44916
45414
  /**
@@ -45084,7 +45582,7 @@ function convertRulesyncToContinuePermissions({ config, logger }) {
45084
45582
  logger?.warn(`Continue permissions.yaml cannot hold a parenthesis inside a pattern, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
45085
45583
  continue;
45086
45584
  }
45087
- if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$5) {
45585
+ if (toolName === "*" && pattern !== CATCH_ALL_PATTERN$6) {
45088
45586
  logger?.warn(`Continue permissions.yaml cannot scope a pattern to every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
45089
45587
  continue;
45090
45588
  }
@@ -45109,7 +45607,7 @@ function convertContinuePermissionsToRulesync(lists) {
45109
45607
  const parsedEntry = parseContinuePermissionEntry(entry);
45110
45608
  if (parsedEntry === void 0) continue;
45111
45609
  if (isPrototypePollutionKey(parsedEntry.toolName) || isPrototypePollutionKey(parsedEntry.pattern)) continue;
45112
- const category = toCanonicalToolName$5(parsedEntry.toolName);
45610
+ const category = toCanonicalToolName$6(parsedEntry.toolName);
45113
45611
  permission[category] ??= {};
45114
45612
  permission[category][parsedEntry.pattern] ??= action;
45115
45613
  }
@@ -45433,14 +45931,14 @@ var CopilotcliPermissions = class CopilotcliPermissions extends ToolPermissions
45433
45931
  };
45434
45932
  //#endregion
45435
45933
  //#region src/features/permissions/crush-permissions.ts
45436
- const CATCH_ALL_PATTERN$4 = "*";
45934
+ const CATCH_ALL_PATTERN$5 = "*";
45437
45935
  /**
45438
45936
  * A run of `*` (`**`, `***`) is the catch-all spelled another way: Crush has no
45439
45937
  * argument patterns at all, so it is the only pattern a rule can carry and
45440
45938
  * still be written.
45441
45939
  */
45442
45940
  function normalizeCatchAllPattern(pattern) {
45443
- return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$4 : pattern;
45941
+ return /^\*+$/.test(pattern) ? CATCH_ALL_PATTERN$5 : pattern;
45444
45942
  }
45445
45943
  const CANONICAL_TO_CRUSH_TOOL_NAMES = {
45446
45944
  bash: "bash",
@@ -45452,20 +45950,20 @@ const CANONICAL_TO_CRUSH_TOOL_NAMES = {
45452
45950
  webfetch: "fetch"
45453
45951
  };
45454
45952
  const CRUSH_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_CRUSH_TOOL_NAMES).map(([k, v]) => [v, k]));
45455
- const MCP_CANONICAL_PREFIX$4 = "mcp__";
45953
+ const MCP_CANONICAL_PREFIX$5 = "mcp__";
45456
45954
  const MCP_CRUSH_PREFIX = "mcp_";
45457
45955
  function toCrushToolName(canonical) {
45458
45956
  if (Object.hasOwn(CANONICAL_TO_CRUSH_TOOL_NAMES, canonical)) return CANONICAL_TO_CRUSH_TOOL_NAMES[canonical] ?? canonical;
45459
- if (canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
45957
+ if (canonical.startsWith(MCP_CANONICAL_PREFIX$5) && canonical.length > 5) return `${MCP_CRUSH_PREFIX}${canonical.slice(5).replaceAll("__", "_")}`;
45460
45958
  return canonical;
45461
45959
  }
45462
- function toCanonicalToolName$4(crushName) {
45960
+ function toCanonicalToolName$5(crushName) {
45463
45961
  return Object.hasOwn(CRUSH_TO_CANONICAL_TOOL_NAMES, crushName) ? CRUSH_TO_CANONICAL_TOOL_NAMES[crushName] ?? crushName : crushName;
45464
45962
  }
45465
45963
  function isMcpToolName(crushName) {
45466
45964
  return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
45467
45965
  }
45468
- function uniq$9(values) {
45966
+ function uniq$10(values) {
45469
45967
  return [...new Set(values)];
45470
45968
  }
45471
45969
  /**
@@ -45571,8 +46069,8 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
45571
46069
  const managedToolNames = managedCrushToolNames(config);
45572
46070
  const preservedAllowed = (isStringArray$2(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
45573
46071
  const preservedDisabled = (isStringArray$2(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
45574
- const allowedList = uniq$9([...preservedAllowed, ...generated.allowed]);
45575
- const disabledList = uniq$9([...preservedDisabled, ...generated.disabled]);
46072
+ const allowedList = uniq$10([...preservedAllowed, ...generated.allowed]);
46073
+ const disabledList = uniq$10([...preservedDisabled, ...generated.disabled]);
45576
46074
  const patch = {};
45577
46075
  if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
45578
46076
  if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
@@ -45639,7 +46137,7 @@ function resolveCategoryAction({ category, toolName, rules, logger }) {
45639
46137
  for (const [rawPattern, action] of Object.entries(rules)) {
45640
46138
  if (isPrototypePollutionKey(rawPattern)) continue;
45641
46139
  const pattern = normalizeCatchAllPattern(rawPattern);
45642
- if (pattern === CATCH_ALL_PATTERN$4) {
46140
+ if (pattern === CATCH_ALL_PATTERN$5) {
45643
46141
  catchAllAction = action;
45644
46142
  continue;
45645
46143
  }
@@ -45689,8 +46187,8 @@ function convertRulesyncToCrushLists({ config, logger }) {
45689
46187
  else if (catchAllAction === "deny") lists.disabled.push(toolName);
45690
46188
  }
45691
46189
  return {
45692
- allowed: uniq$9(lists.allowed),
45693
- disabled: uniq$9(lists.disabled)
46190
+ allowed: uniq$10(lists.allowed),
46191
+ disabled: uniq$10(lists.disabled)
45694
46192
  };
45695
46193
  }
45696
46194
  /**
@@ -45712,11 +46210,11 @@ function convertCrushListsToRulesync({ allowed, disabled }) {
45712
46210
  };
45713
46211
  for (const entry of disabled) {
45714
46212
  if (entry === "" || isPrototypePollutionKey(entry)) continue;
45715
- bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] = "deny";
46213
+ bucketFor(toCanonicalToolName$5(entry))[CATCH_ALL_PATTERN$5] = "deny";
45716
46214
  }
45717
46215
  for (const entry of allowed) {
45718
46216
  if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
45719
- bucketFor(toCanonicalToolName$4(entry))[CATCH_ALL_PATTERN$4] ??= "allow";
46217
+ bucketFor(toCanonicalToolName$5(entry))[CATCH_ALL_PATTERN$5] ??= "allow";
45720
46218
  }
45721
46219
  return { permission };
45722
46220
  }
@@ -45763,13 +46261,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
45763
46261
  WebFetch: "webfetch",
45764
46262
  Mcp: "mcp"
45765
46263
  };
45766
- const MCP_CANONICAL_PREFIX$3 = "mcp__";
46264
+ const MCP_CANONICAL_PREFIX$4 = "mcp__";
45767
46265
  /**
45768
46266
  * Returns true if the canonical category is the per-tool MCP form
45769
46267
  * `mcp__<server>__<tool>`.
45770
46268
  */
45771
46269
  function isMcpScopedCategory(canonical) {
45772
- return canonical.startsWith(MCP_CANONICAL_PREFIX$3) && canonical.length > 5;
46270
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$4) && canonical.length > 5;
45773
46271
  }
45774
46272
  function toCursorType(canonical) {
45775
46273
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -45803,7 +46301,7 @@ function toCanonicalCategory$1(cursorType, pattern) {
45803
46301
  if (match) {
45804
46302
  const server = match[1] ?? "*";
45805
46303
  const tool = match[2] ?? "*";
45806
- return `${MCP_CANONICAL_PREFIX$3}${server}__${tool}`;
46304
+ return `${MCP_CANONICAL_PREFIX$4}${server}__${tool}`;
45807
46305
  }
45808
46306
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
45809
46307
  }
@@ -46096,7 +46594,7 @@ function convertCursorToRulesyncPermissions(params) {
46096
46594
  const { type, pattern } = parseCursorPermissionEntry(entry);
46097
46595
  const canonical = toCanonicalCategory$1(type, pattern);
46098
46596
  if (!permission[canonical]) permission[canonical] = {};
46099
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$3) ? "*" : pattern;
46597
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$4) ? "*" : pattern;
46100
46598
  permission[canonical][canonicalPattern] = action;
46101
46599
  }
46102
46600
  };
@@ -47615,7 +48113,7 @@ function convertFactorydroidToRulesyncPermissions({ allow, deny }) {
47615
48113
  //#region src/features/permissions/goose-permissions.ts
47616
48114
  const GOOSE_GLOBAL_ONLY_MESSAGE = "Goose permissions are global-only; use --global to sync ~/.config/goose/permission.yaml";
47617
48115
  const GOOSE_USER_KEY = "user";
47618
- const CATCH_ALL_PATTERN$3 = "*";
48116
+ const CATCH_ALL_PATTERN$4 = "*";
47619
48117
  const RULESYNC_TO_GOOSE_TOOL_NAME = {
47620
48118
  bash: "developer__shell",
47621
48119
  edit: "developer__text_editor",
@@ -47777,7 +48275,7 @@ function convertRulesyncToGoosePermissionConfig({ config, logger }) {
47777
48275
  for (const [category, rules] of orderedEntries) {
47778
48276
  const toolName = RULESYNC_TO_GOOSE_TOOL_NAME[category] ?? category;
47779
48277
  for (const [pattern, action] of Object.entries(rules)) {
47780
- if (pattern !== CATCH_ALL_PATTERN$3) {
48278
+ if (pattern !== CATCH_ALL_PATTERN$4) {
47781
48279
  logger?.warn(`Goose permission.yaml lists whole tool names, so the per-pattern "${action}" rule for "${category}" (pattern "${pattern}") cannot be expressed and was skipped.`);
47782
48280
  continue;
47783
48281
  }
@@ -47806,7 +48304,7 @@ function convertGoosePermissionConfigToRulesync(userPermission) {
47806
48304
  for (const toolName of toolNames) {
47807
48305
  const category = GOOSE_TO_RULESYNC_TOOL_NAME[toolName] ?? toolName;
47808
48306
  permission[category] ??= {};
47809
- permission[category][CATCH_ALL_PATTERN$3] = action;
48307
+ permission[category][CATCH_ALL_PATTERN$4] = action;
47810
48308
  }
47811
48309
  }
47812
48310
  return { permission };
@@ -47817,8 +48315,8 @@ const GROKCLI_UI_KEY = "ui";
47817
48315
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
47818
48316
  const GROKCLI_PERMISSION_KEY = "permission";
47819
48317
  const GROKCLI_AUTO_PERMISSION_MODE = "auto";
47820
- const CATCH_ALL_PATTERN$2 = "*";
47821
- const MCP_CANONICAL_PREFIX$2 = "mcp__";
48318
+ const CATCH_ALL_PATTERN$3 = "*";
48319
+ const MCP_CANONICAL_PREFIX$3 = "mcp__";
47822
48320
  const CATEGORY_TO_GROK_TOOL = {
47823
48321
  bash: "Bash",
47824
48322
  read: "Read",
@@ -47859,14 +48357,14 @@ const GROKCLI_PERMISSION_RULES_KEY = "rules";
47859
48357
  * concrete pattern emits `Tool(pattern)`.
47860
48358
  */
47861
48359
  function buildGrokEntry(category, pattern) {
47862
- if (category.startsWith(MCP_CANONICAL_PREFIX$2)) {
48360
+ if (category.startsWith(MCP_CANONICAL_PREFIX$3)) {
47863
48361
  const remainder = category.slice(5);
47864
48362
  return remainder.length > 0 ? `${GROK_MCP_TOOL}(${remainder})` : GROK_MCP_TOOL;
47865
48363
  }
47866
- if (category === "mcp") return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? GROK_MCP_TOOL : `${GROK_MCP_TOOL}(${pattern})`;
48364
+ if (category === "mcp") return pattern === CATCH_ALL_PATTERN$3 || pattern === "" ? GROK_MCP_TOOL : `${GROK_MCP_TOOL}(${pattern})`;
47867
48365
  const tool = CATEGORY_TO_GROK_TOOL[category];
47868
48366
  if (tool === void 0) return null;
47869
- return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? tool : `${tool}(${pattern})`;
48367
+ return pattern === CATCH_ALL_PATTERN$3 || pattern === "" ? tool : `${tool}(${pattern})`;
47870
48368
  }
47871
48369
  /**
47872
48370
  * Parse a Grok Claude-style permission entry back into a canonical category +
@@ -47886,17 +48384,17 @@ function parseGrokEntry(entry) {
47886
48384
  inner = trimmed.slice(parenIndex + 1, -1).trim();
47887
48385
  }
47888
48386
  if (tool === GROK_MCP_TOOL) return inner.length > 0 ? {
47889
- category: `${MCP_CANONICAL_PREFIX$2}${inner}`,
47890
- pattern: CATCH_ALL_PATTERN$2
48387
+ category: `${MCP_CANONICAL_PREFIX$3}${inner}`,
48388
+ pattern: CATCH_ALL_PATTERN$3
47891
48389
  } : {
47892
48390
  category: "mcp",
47893
- pattern: CATCH_ALL_PATTERN$2
48391
+ pattern: CATCH_ALL_PATTERN$3
47894
48392
  };
47895
48393
  const category = GROK_TOOL_TO_CATEGORY[tool];
47896
48394
  if (category === void 0) return null;
47897
48395
  return {
47898
48396
  category,
47899
- pattern: inner.length > 0 ? inner : CATCH_ALL_PATTERN$2
48397
+ pattern: inner.length > 0 ? inner : CATCH_ALL_PATTERN$3
47900
48398
  };
47901
48399
  }
47902
48400
  /**
@@ -47917,15 +48415,15 @@ function parseGrokRule(rule) {
47917
48415
  if (typeof tool !== "string") return null;
47918
48416
  if (pattern !== void 0 && typeof pattern !== "string") return null;
47919
48417
  const trimmedPattern = pattern?.trim() ?? "";
47920
- const resolvedPattern = trimmedPattern.length > 0 ? trimmedPattern : CATCH_ALL_PATTERN$2;
48418
+ const resolvedPattern = trimmedPattern.length > 0 ? trimmedPattern : CATCH_ALL_PATTERN$3;
47921
48419
  const lowerTool = tool.trim().toLowerCase();
47922
- if (GROK_MCP_TOOL_ALIASES.has(lowerTool)) return resolvedPattern === CATCH_ALL_PATTERN$2 ? {
48420
+ if (GROK_MCP_TOOL_ALIASES.has(lowerTool)) return resolvedPattern === CATCH_ALL_PATTERN$3 ? {
47923
48421
  category: "mcp",
47924
- pattern: CATCH_ALL_PATTERN$2,
48422
+ pattern: CATCH_ALL_PATTERN$3,
47925
48423
  action
47926
48424
  } : {
47927
- category: `${MCP_CANONICAL_PREFIX$2}${resolvedPattern}`,
47928
- pattern: CATCH_ALL_PATTERN$2,
48425
+ category: `${MCP_CANONICAL_PREFIX$3}${resolvedPattern}`,
48426
+ pattern: CATCH_ALL_PATTERN$3,
47929
48427
  action
47930
48428
  };
47931
48429
  if (!Object.hasOwn(GROK_TOOL_TO_CATEGORY_LOWER, lowerTool)) return null;
@@ -48073,7 +48571,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
48073
48571
  throw new Error(`Failed to parse Grok config.toml content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
48074
48572
  }
48075
48573
  const fineGrained = parseGrokPermissionArrays(isRecord$1(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
48076
- const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$2]: legacyModeAction(parsed) } } };
48574
+ const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$3]: legacyModeAction(parsed) } } };
48077
48575
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
48078
48576
  }
48079
48577
  validate() {
@@ -49953,6 +50451,567 @@ var PiPermissions = class PiPermissions extends ToolPermissions {
49953
50451
  }
49954
50452
  };
49955
50453
  //#endregion
50454
+ //#region src/features/permissions/pool-permissions.ts
50455
+ const POOL_ALLOW_KEY = "allow";
50456
+ const POOL_DENY_KEY = "deny";
50457
+ const POOL_DISABLED_KEY = "disabled";
50458
+ const POOL_PATH_KEY = "path";
50459
+ const POOL_WRITE_KEY = "write";
50460
+ const CATCH_ALL_PATTERN$2 = "*";
50461
+ const PATHS_CATCH_ALL_PATTERN = "**";
50462
+ const CANONICAL_TO_POOL_TOOL_NAMES = {
50463
+ bash: "shell",
50464
+ webfetch: "web_fetch",
50465
+ websearch: "web_search"
50466
+ };
50467
+ const POOL_TO_CANONICAL_TOOL_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_POOL_TOOL_NAMES).map(([k, v]) => [v, k]));
50468
+ const READ_CATEGORY = "read";
50469
+ const WRITE_CATEGORIES = ["edit", "write"];
50470
+ const FILE_CATEGORIES = [READ_CATEGORY, ...WRITE_CATEGORIES];
50471
+ const MCP_CANONICAL_PREFIX$2 = "mcp__";
50472
+ /**
50473
+ * Single spelling of the settings.yaml codec/policy, matching the
50474
+ * `SHARED_CONFIG_OWNERSHIP` declaration for both scopes: fail closed on an
50475
+ * unparseable root rather than replacing the user's primary Pool settings with
50476
+ * generated output.
50477
+ */
50478
+ function parsePoolSettings$1(fileContent, filePath) {
50479
+ return parseSharedConfig({
50480
+ format: "yaml",
50481
+ fileContent,
50482
+ filePath,
50483
+ invalidRootPolicy: "error"
50484
+ });
50485
+ }
50486
+ function toPoolToolName(canonical) {
50487
+ return lookupOwn({
50488
+ record: CANONICAL_TO_POOL_TOOL_NAMES,
50489
+ key: canonical
50490
+ }) ?? canonical;
50491
+ }
50492
+ function toCanonicalToolName$4(poolName) {
50493
+ return lookupOwn({
50494
+ record: POOL_TO_CANONICAL_TOOL_NAMES,
50495
+ key: poolName
50496
+ }) ?? poolName;
50497
+ }
50498
+ function isMcpCategory(category) {
50499
+ return category.startsWith(MCP_CANONICAL_PREFIX$2) && category.length > 5;
50500
+ }
50501
+ function isFileCategory(category) {
50502
+ return FILE_CATEGORIES.includes(category);
50503
+ }
50504
+ /**
50505
+ * Pool's tool rules know one wildcard, `*`, which matches anything including
50506
+ * `/` — the same set a canonical `*` names — so a `**` run is collapsed to
50507
+ * keep the file in Pool's own spelling. `?`, `[...]` and `{a,b}` are literal
50508
+ * characters to Pool, so a pattern that relies on them cannot be written.
50509
+ */
50510
+ function toPoolToolPattern(pattern) {
50511
+ if (/[?[{]/.test(pattern)) return void 0;
50512
+ return pattern.replaceAll(/\*{2,}/g, "*");
50513
+ }
50514
+ /**
50515
+ * Pool's path globs know `*` and `**`, so a bare `*` run is written as `**`
50516
+ * to keep it matching anything. `?`, `[...]` and `{a,b}` are literal
50517
+ * characters to Pool here as well, so a pattern that relies on them cannot be
50518
+ * written.
50519
+ */
50520
+ function toPoolPathPattern(pattern) {
50521
+ if (/[?[{]/.test(pattern)) return void 0;
50522
+ return /^\*+$/.test(pattern) ? PATHS_CATCH_ALL_PATTERN : pattern;
50523
+ }
50524
+ function isHomeOrAbsolutePath(pattern) {
50525
+ return (0, node_path.isAbsolute)(pattern) || pattern === "~" || pattern.startsWith("~/");
50526
+ }
50527
+ function rulesOf(permission, category) {
50528
+ return lookupOwn({
50529
+ record: permission,
50530
+ key: category
50531
+ }) ?? {};
50532
+ }
50533
+ function patternsWithAction(rules, actions) {
50534
+ return Object.entries(rules).filter(([pattern, action]) => !isPrototypePollutionKey(pattern) && actions.includes(action)).map(([pattern]) => pattern);
50535
+ }
50536
+ function asRestrictions(patterns) {
50537
+ return (0, es_toolkit.uniq)(patterns).map((pattern) => ({
50538
+ pattern,
50539
+ fromAllToolsCategory: false
50540
+ }));
50541
+ }
50542
+ function listPatterns(patterns) {
50543
+ return patterns.map((pattern) => quoteValueForWarning(pattern)).join(", ");
50544
+ }
50545
+ /**
50546
+ * Permissions adapter for Pool (Poolside's coding agent CLI).
50547
+ *
50548
+ * Pool keeps its permission rules in the same YAML settings file as its MCP
50549
+ * servers — `.poolside/settings.yaml` (project) / `~/.config/poolside/settings.yaml`
50550
+ * (global) — under two top-level keys:
50551
+ * - `tools.<name>`: `allow` and `deny` pattern lists matched against the
50552
+ * tool's argument (the command for `shell`, the URL for `web_fetch`, ...),
50553
+ * plus `disabled: true` to remove the tool. A denied pattern wins over an
50554
+ * allowed one; a call that matches neither prompts. Only `*` is a wildcard.
50555
+ * - `paths`: `allow` entries (`{ path }`, read-only unless `write: true`)
50556
+ * and `deny` entries (`{ path }`) with `*`/`**` globs; deny wins.
50557
+ *
50558
+ * Mapping (rulesync canonical -> Pool):
50559
+ * - `bash` -> `tools.shell`, `webfetch` -> `tools.web_fetch`,
50560
+ * `websearch` -> `tools.web_search`; any other non-file category passes
50561
+ * through as `tools.<category>`. `allow` -> `allow`, `deny` -> `deny`,
50562
+ * `ask` -> nothing (prompting is Pool's default) — but an `allow` that
50563
+ * overlaps an `ask` of any category landing on the same Pool tool, or a
50564
+ * deny Pool cannot spell, is withheld, since an allowed pattern would
50565
+ * silence the prompt the config asks for.
50566
+ * - `read` / `edit` / `write` -> `paths`: a `read` allow is a read-only
50567
+ * entry, an `edit` or `write` allow adds `write: true`, a `read` deny is a
50568
+ * deny entry. Pool cannot deny writes without denying reads, so an
50569
+ * `edit`/`write` deny that is not also a `read` deny only withholds the
50570
+ * write flag of the entries it overlaps (Pool prompts for those writes).
50571
+ * - `mcp__<server>__<tool>` is skipped: Pool approves MCP tools per server
50572
+ * under `mcp_servers.<server>.allow`, authored as `poolAllow` in
50573
+ * `.rulesync/mcp.json`. The all-tools `*` category is mirrored onto
50574
+ * `bash` by `honorAllToolsOnBash` and otherwise skipped.
50575
+ *
50576
+ * Project settings take project-relative paths and the user settings absolute
50577
+ * or `~` paths, so a path of the other kind is skipped with a warning at each
50578
+ * scope. The managed tools' `allow`/`deny` lists and, whenever the canonical
50579
+ * config carries a file category, the `paths` lists are rebuilt; every other
50580
+ * key (`disabled`, unmanaged tools, `mcp_servers`, `pool`, `sandbox`, ...) is
50581
+ * kept, and the file is never deleted.
50582
+ *
50583
+ * @see https://docs.poolside.ai/settings-file-reference
50584
+ */
50585
+ var PoolPermissions = class PoolPermissions extends ToolPermissions {
50586
+ settings;
50587
+ constructor(params) {
50588
+ super({
50589
+ ...params,
50590
+ fileContent: params.fileContent ?? ""
50591
+ });
50592
+ this.settings = parsePoolSettings$1(this.fileContent ?? "", (0, node_path.join)(this.relativeDirPath, this.relativeFilePath));
50593
+ }
50594
+ getSettings() {
50595
+ return this.settings;
50596
+ }
50597
+ isDeletable() {
50598
+ return false;
50599
+ }
50600
+ /**
50601
+ * settings.yaml is Pool's file: rulesync merges into it when it exists but
50602
+ * does not create one that holds nothing of its own — an absent file and an
50603
+ * absent rule both mean "prompt".
50604
+ */
50605
+ shouldSkipCreationWhenPayloadEmpty() {
50606
+ return true;
50607
+ }
50608
+ static getSettablePaths({ global = false } = {}) {
50609
+ return {
50610
+ relativeDirPath: global ? POOL_GLOBAL_DIR : POOL_DIR,
50611
+ relativeFilePath: POOL_SETTINGS_FILE_NAME
50612
+ };
50613
+ }
50614
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
50615
+ const paths = this.getSettablePaths({ global });
50616
+ const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "";
50617
+ return new PoolPermissions({
50618
+ outputRoot,
50619
+ relativeDirPath: paths.relativeDirPath,
50620
+ relativeFilePath: paths.relativeFilePath,
50621
+ fileContent,
50622
+ validate,
50623
+ global
50624
+ });
50625
+ }
50626
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
50627
+ const paths = this.getSettablePaths({ global });
50628
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
50629
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
50630
+ const existing = parsePoolSettings$1(existingContent, filePath);
50631
+ const patch = buildPoolPermissionsPatch({
50632
+ config: rulesyncPermissions.getJson(),
50633
+ existing,
50634
+ global,
50635
+ logger
50636
+ });
50637
+ return new PoolPermissions({
50638
+ outputRoot,
50639
+ relativeDirPath: paths.relativeDirPath,
50640
+ relativeFilePath: paths.relativeFilePath,
50641
+ fileContent: applySharedConfigPatch({
50642
+ fileKey: sharedConfigFileKey(paths),
50643
+ feature: "permissions",
50644
+ existingContent,
50645
+ patch,
50646
+ filePath,
50647
+ logger
50648
+ }),
50649
+ validate: true,
50650
+ global
50651
+ });
50652
+ }
50653
+ toRulesyncPermissions() {
50654
+ const rulesyncConfig = convertPoolToRulesync({
50655
+ tools: isRecord$1(this.settings["tools"]) ? this.settings[POOL_TOOLS_KEY] : {},
50656
+ paths: isRecord$1(this.settings["paths"]) ? this.settings[POOL_PATHS_KEY] : {}
50657
+ });
50658
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
50659
+ }
50660
+ validate() {
50661
+ return {
50662
+ success: true,
50663
+ error: null
50664
+ };
50665
+ }
50666
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
50667
+ return new PoolPermissions({
50668
+ outputRoot,
50669
+ relativeDirPath,
50670
+ relativeFilePath,
50671
+ fileContent: "",
50672
+ validate: false,
50673
+ global
50674
+ });
50675
+ }
50676
+ };
50677
+ /**
50678
+ * The deep-merge patch that rewrites the managed `tools.<name>` lists and,
50679
+ * when the canonical config carries a file category, the `paths` lists. An
50680
+ * empty list retracts its key (`undefined` deletes under deep-merge), and a
50681
+ * block is only touched when there is something to write into it or it
50682
+ * already exists, so a config without rules does not grow an empty
50683
+ * `tools: {}` / `paths: {}`.
50684
+ */
50685
+ function buildPoolPermissionsPatch({ config, existing, global, logger }) {
50686
+ const permission = honorAllToolsOnBash(config.permission);
50687
+ const existingTools = isRecord$1(existing["tools"]) ? existing[POOL_TOOLS_KEY] : void 0;
50688
+ const existingPaths = isRecord$1(existing["paths"]) ? existing[POOL_PATHS_KEY] : void 0;
50689
+ const patch = {};
50690
+ const toolsPatch = {};
50691
+ for (const [toolName, lists] of convertRulesyncToPoolTools({
50692
+ permission,
50693
+ logger
50694
+ })) {
50695
+ const existingTool = existingTools === void 0 ? void 0 : lookupOwn({
50696
+ record: existingTools,
50697
+ key: toolName
50698
+ });
50699
+ if (lists.allow.length === 0 && lists.deny.length === 0 && existingTool === void 0) continue;
50700
+ toolsPatch[toolName] = {
50701
+ [POOL_ALLOW_KEY]: lists.allow.length > 0 ? lists.allow : void 0,
50702
+ [POOL_DENY_KEY]: lists.deny.length > 0 ? lists.deny : void 0
50703
+ };
50704
+ }
50705
+ if (Object.keys(toolsPatch).length > 0) patch[POOL_TOOLS_KEY] = toolsPatch;
50706
+ if (FILE_CATEGORIES.some((category) => lookupOwn({
50707
+ record: permission,
50708
+ key: category
50709
+ }))) {
50710
+ const lists = convertRulesyncToPoolPaths({
50711
+ permission,
50712
+ global,
50713
+ logger
50714
+ });
50715
+ if (lists.allow.length > 0 || lists.deny.length > 0 || existingPaths !== void 0) patch[POOL_PATHS_KEY] = {
50716
+ [POOL_ALLOW_KEY]: lists.allow.length > 0 ? lists.allow : void 0,
50717
+ [POOL_DENY_KEY]: lists.deny.length > 0 ? lists.deny : void 0
50718
+ };
50719
+ }
50720
+ return patch;
50721
+ }
50722
+ /**
50723
+ * Convert one non-file category into a Pool tool's `allow`/`deny` lists. A
50724
+ * deny Pool can spell needs no withholding (Pool lets it win over an allow),
50725
+ * but an `ask` has no list of its own — a call Pool does not match prompts —
50726
+ * so an allow that overlaps one is withheld and reported, and the tool keeps
50727
+ * prompting. A deny Pool cannot spell (its pattern uses a glob form other
50728
+ * than `*`) is skipped, and then withholds the allows it overlaps the same
50729
+ * way: written, such an allow would auto-approve the very command the deny
50730
+ * was meant to stop. `restrictions` carries the ask patterns and the skipped
50731
+ * deny patterns of every category that lands on the same Pool tool, because
50732
+ * Pool matches the joined lists as one.
50733
+ */
50734
+ function convertToolCategory({ category, toolName, rules, restrictions, logger }) {
50735
+ const lists = {
50736
+ allow: [],
50737
+ deny: []
50738
+ };
50739
+ const shadowingRestrictions = createShadowingRestrictionsTest(asRestrictions(restrictions));
50740
+ for (const [rawPattern, action] of Object.entries(rules)) {
50741
+ if (isPrototypePollutionKey(rawPattern) || action === "ask") continue;
50742
+ const pattern = toPoolToolPattern(rawPattern);
50743
+ if (pattern === void 0) {
50744
+ logger?.warn(`Pool tool rules treat only "*" as a wildcard, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
50745
+ continue;
50746
+ }
50747
+ if (action === "deny") {
50748
+ lists.deny.push(pattern);
50749
+ continue;
50750
+ }
50751
+ const shadowing = shadowingRestrictions(rawPattern);
50752
+ if (shadowing.length > 0) {
50753
+ logger?.warn(`Pool has no list for an "ask" or for a deny it cannot spell (an unmatched call prompts), so the "allow" rule for "${category}" (pattern ${quoteValueForWarning(rawPattern)}) was withheld because it overlaps the restriction(s) ${listPatterns(shadowing)}; Pool keeps prompting for "${toolName}".`);
50754
+ continue;
50755
+ }
50756
+ lists.allow.push(pattern);
50757
+ }
50758
+ return {
50759
+ allow: (0, es_toolkit.uniq)(lists.allow),
50760
+ deny: (0, es_toolkit.uniq)(lists.deny)
50761
+ };
50762
+ }
50763
+ function unrepresentableDenies(rules) {
50764
+ return patternsWithAction(rules, ["deny"]).filter((pattern) => toPoolToolPattern(pattern) === void 0);
50765
+ }
50766
+ /**
50767
+ * Group the non-file categories by the Pool tool they land on, reporting the
50768
+ * ones Pool has no place for. Two categories can resolve to the same tool
50769
+ * (`bash` and a pass-through `shell`).
50770
+ */
50771
+ function groupCategoriesByPoolTool({ permission, logger }) {
50772
+ const byTool = /* @__PURE__ */ new Map();
50773
+ for (const [category, rules] of Object.entries(permission)) {
50774
+ if (isPrototypePollutionKey(category) || isFileCategory(category)) continue;
50775
+ if (category === "*") {
50776
+ for (const [rawPattern, action] of Object.entries(rules)) {
50777
+ if (isPrototypePollutionKey(rawPattern)) continue;
50778
+ logger?.warn(`Pool has no rule that spans every tool, so the "${action}" rule for "*" (pattern ${quoteValueForWarning(rawPattern)}) was skipped.`);
50779
+ }
50780
+ continue;
50781
+ }
50782
+ if (isMcpCategory(category)) {
50783
+ logger?.warn(`Pool approves MCP tools per server under "mcp_servers.<server>.allow", so the rules for "${category}" were skipped; author them as "poolAllow" on the server in .rulesync/mcp.json instead.`);
50784
+ continue;
50785
+ }
50786
+ const toolName = toPoolToolName(category);
50787
+ byTool.set(toolName, [...byTool.get(toolName) ?? [], [category, rules]]);
50788
+ }
50789
+ return byTool;
50790
+ }
50791
+ /**
50792
+ * Convert the non-file categories into Pool tool lists, keyed by Pool tool
50793
+ * name. The categories that land on the same tool are converted against the
50794
+ * union of their `ask` patterns and their lists are joined, which is safe
50795
+ * because a deny wins over an allow in Pool whichever category wrote it.
50796
+ */
50797
+ function convertRulesyncToPoolTools({ permission, logger }) {
50798
+ const byTool = /* @__PURE__ */ new Map();
50799
+ for (const [toolName, categories] of groupCategoriesByPoolTool({
50800
+ permission,
50801
+ logger
50802
+ })) {
50803
+ const restrictions = categories.flatMap(([, rules]) => [...patternsWithAction(rules, ["ask"]), ...unrepresentableDenies(rules)]);
50804
+ const lists = {
50805
+ allow: [],
50806
+ deny: []
50807
+ };
50808
+ for (const [category, rules] of categories) {
50809
+ const converted = convertToolCategory({
50810
+ category,
50811
+ toolName,
50812
+ rules,
50813
+ restrictions,
50814
+ logger
50815
+ });
50816
+ lists.allow.push(...converted.allow);
50817
+ lists.deny.push(...converted.deny);
50818
+ }
50819
+ byTool.set(toolName, {
50820
+ allow: (0, es_toolkit.uniq)(lists.allow),
50821
+ deny: (0, es_toolkit.uniq)(lists.deny)
50822
+ });
50823
+ }
50824
+ return byTool;
50825
+ }
50826
+ /**
50827
+ * Convert the file categories into Pool's `paths` lists.
50828
+ *
50829
+ * An allow entry grants reading, and `write: true` grants writing on top, so
50830
+ * a `read` allow is withheld by an overlapping `read` ask and a write allow by
50831
+ * an overlapping `edit`/`write` ask — and by a `read` ask, since the write
50832
+ * entry would read too. A `read` deny lands in `paths.deny`, which Pool lets
50833
+ * win over any allow, and covers an `edit`/`write` deny of the same pattern,
50834
+ * so neither withholds anything; an `edit`/`write` deny without a `read` deny
50835
+ * cannot be written (Pool's deny blocks reads as well), so it is reported and
50836
+ * only withholds the write flags it overlaps. A deny Pool cannot spell is
50837
+ * skipped the same way and withholds the allows it overlaps, so it is honored
50838
+ * by prompting rather than dropped.
50839
+ */
50840
+ function convertRulesyncToPoolPaths({ permission, global, logger }) {
50841
+ const readRules = rulesOf(permission, READ_CATEGORY);
50842
+ const writeRulesByCategory = WRITE_CATEGORIES.map((category) => [category, rulesOf(permission, category)]);
50843
+ const readAsks = patternsWithAction(readRules, ["ask"]);
50844
+ const fitsScope = ({ category, action, pattern }) => {
50845
+ if (isHomeOrAbsolutePath(pattern) === global) return true;
50846
+ logger?.warn(global ? `Pool's user settings take absolute or "~" paths, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped; author it in ${(0, node_path.join)(POOL_DIR, POOL_SETTINGS_FILE_NAME)} (without --global) instead.` : `Pool's project settings take project-relative paths, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped; author it in ~/${(0, node_path.join)(POOL_GLOBAL_DIR, POOL_SETTINGS_FILE_NAME)} (via --global) instead.`);
50847
+ return false;
50848
+ };
50849
+ const deny = [];
50850
+ const readRestrictions = [...readAsks];
50851
+ for (const pattern of patternsWithAction(readRules, ["deny"])) {
50852
+ const path = toPoolPathPattern(pattern);
50853
+ if (path === void 0) {
50854
+ warnSkippedPathPattern({
50855
+ category: READ_CATEGORY,
50856
+ action: "deny",
50857
+ pattern,
50858
+ logger
50859
+ });
50860
+ readRestrictions.push(pattern);
50861
+ continue;
50862
+ }
50863
+ if (!fitsScope({
50864
+ category: READ_CATEGORY,
50865
+ action: "deny",
50866
+ pattern
50867
+ })) continue;
50868
+ if (!deny.some((entry) => entry[POOL_PATH_KEY] === path)) deny.push({ [POOL_PATH_KEY]: path });
50869
+ }
50870
+ const isDeniedForRead = (pattern) => {
50871
+ const path = toPoolPathPattern(pattern);
50872
+ return path !== void 0 && deny.some((entry) => entry[POOL_PATH_KEY] === path);
50873
+ };
50874
+ const writeRestrictions = writeRulesByCategory.flatMap(([, rules]) => [...patternsWithAction(rules, ["ask"]), ...patternsWithAction(rules, ["deny"]).filter((pattern) => !isDeniedForRead(pattern))]);
50875
+ reportUnwrittenWriteDenies({
50876
+ writeRulesByCategory,
50877
+ isDeniedForRead,
50878
+ logger
50879
+ });
50880
+ const shadowingReadRestrictions = createShadowingRestrictionsTest(asRestrictions(readRestrictions));
50881
+ const shadowingWriteRestrictions = createShadowingRestrictionsTest(asRestrictions([...readRestrictions, ...writeRestrictions]));
50882
+ const allowByPath = /* @__PURE__ */ new Map();
50883
+ for (const pattern of patternsWithAction(readRules, ["allow"])) {
50884
+ if (!fitsScope({
50885
+ category: READ_CATEGORY,
50886
+ action: "allow",
50887
+ pattern
50888
+ })) continue;
50889
+ const path = toPoolPathPattern(pattern);
50890
+ if (path === void 0) {
50891
+ warnSkippedPathPattern({
50892
+ category: READ_CATEGORY,
50893
+ action: "allow",
50894
+ pattern,
50895
+ logger
50896
+ });
50897
+ continue;
50898
+ }
50899
+ const shadowing = shadowingReadRestrictions(pattern);
50900
+ if (shadowing.length > 0) {
50901
+ logger?.warn(`Pool has no list for an "ask" or for a deny it cannot spell (an unmatched path prompts), so the "allow" rule for "read" (pattern ${quoteValueForWarning(pattern)}) was withheld because it overlaps the restriction(s) ${listPatterns(shadowing)}.`);
50902
+ continue;
50903
+ }
50904
+ allowByPath.set(path, { [POOL_PATH_KEY]: path });
50905
+ }
50906
+ for (const [category, rules] of writeRulesByCategory) for (const pattern of patternsWithAction(rules, ["allow"])) {
50907
+ if (!fitsScope({
50908
+ category,
50909
+ action: "allow",
50910
+ pattern
50911
+ })) continue;
50912
+ const path = toPoolPathPattern(pattern);
50913
+ if (path === void 0) {
50914
+ warnSkippedPathPattern({
50915
+ category,
50916
+ action: "allow",
50917
+ pattern,
50918
+ logger
50919
+ });
50920
+ continue;
50921
+ }
50922
+ const shadowing = shadowingWriteRestrictions(pattern);
50923
+ if (shadowing.length > 0) {
50924
+ logger?.warn(`A Pool path allowed with "write: true" can be read and written freely, so the "allow" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was withheld because it overlaps the restriction(s) ${listPatterns(shadowing)}; Pool prompts for those writes.`);
50925
+ continue;
50926
+ }
50927
+ allowByPath.set(path, {
50928
+ [POOL_PATH_KEY]: path,
50929
+ [POOL_WRITE_KEY]: true
50930
+ });
50931
+ }
50932
+ return {
50933
+ allow: [...allowByPath.values()],
50934
+ deny
50935
+ };
50936
+ }
50937
+ function warnSkippedPathPattern({ category, action, pattern, logger }) {
50938
+ logger?.warn(`Pool path rules treat only "*" and "**" as wildcards, so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
50939
+ }
50940
+ function reportUnwrittenWriteDenies({ writeRulesByCategory, isDeniedForRead, logger }) {
50941
+ for (const [category, rules] of writeRulesByCategory) for (const pattern of patternsWithAction(rules, ["deny"])) {
50942
+ if (isDeniedForRead(pattern)) continue;
50943
+ if (toPoolPathPattern(pattern) === void 0) {
50944
+ warnSkippedPathPattern({
50945
+ category,
50946
+ action: "deny",
50947
+ pattern,
50948
+ logger
50949
+ });
50950
+ continue;
50951
+ }
50952
+ logger?.warn(`Pool cannot deny writes without denying reads, so the "deny" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was not written and Pool prompts for those writes instead; deny the pattern under "read" as well to block it entirely.`);
50953
+ }
50954
+ }
50955
+ function isUsablePattern(pattern) {
50956
+ return typeof pattern === "string" && pattern !== "" && !isPrototypePollutionKey(pattern);
50957
+ }
50958
+ function pathOfEntry(entry) {
50959
+ return isRecord$1(entry) && isUsablePattern(entry[POOL_PATH_KEY]) ? entry[POOL_PATH_KEY] : void 0;
50960
+ }
50961
+ /**
50962
+ * Convert one `tools.<name>` block into its canonical bucket. A disabled tool
50963
+ * never runs, so it reads as a catch-all deny and its own lists are ignored;
50964
+ * a denied pattern wins over an allowed one, as it does in Pool.
50965
+ */
50966
+ function importPoolTool({ toolConfig, bucket }) {
50967
+ if (toolConfig[POOL_DISABLED_KEY] === true) {
50968
+ bucket[CATCH_ALL_PATTERN$2] = "deny";
50969
+ return;
50970
+ }
50971
+ const denied = isStringArray$2(toolConfig[POOL_DENY_KEY]) ? toolConfig[POOL_DENY_KEY] : [];
50972
+ const allowed = isStringArray$2(toolConfig[POOL_ALLOW_KEY]) ? toolConfig[POOL_ALLOW_KEY] : [];
50973
+ for (const pattern of denied.filter(isUsablePattern)) bucket[pattern] = "deny";
50974
+ for (const pattern of allowed.filter(isUsablePattern)) bucket[pattern] ??= "allow";
50975
+ }
50976
+ /**
50977
+ * Convert Pool's `tools` and `paths` blocks back into a rulesync config.
50978
+ * `mcp_servers` approvals are the mcp feature's (`poolAllow`) and are not read.
50979
+ */
50980
+ function convertPoolToRulesync({ tools, paths }) {
50981
+ const permission = {};
50982
+ const bucketFor = (category) => {
50983
+ const own = lookupOwn({
50984
+ record: permission,
50985
+ key: category
50986
+ });
50987
+ if (own !== void 0) return own;
50988
+ const created = {};
50989
+ permission[category] = created;
50990
+ return created;
50991
+ };
50992
+ for (const [toolName, toolConfig] of Object.entries(tools)) {
50993
+ if (isPrototypePollutionKey(toolName) || !isRecord$1(toolConfig)) continue;
50994
+ importPoolTool({
50995
+ toolConfig,
50996
+ bucket: bucketFor(toCanonicalToolName$4(toolName))
50997
+ });
50998
+ }
50999
+ const denyEntries = Array.isArray(paths[POOL_DENY_KEY]) ? paths[POOL_DENY_KEY] : [];
51000
+ const allowEntries = Array.isArray(paths[POOL_ALLOW_KEY]) ? paths[POOL_ALLOW_KEY] : [];
51001
+ for (const entry of denyEntries) {
51002
+ const path = pathOfEntry(entry);
51003
+ if (path === void 0) continue;
51004
+ for (const category of FILE_CATEGORIES) bucketFor(category)[path] = "deny";
51005
+ }
51006
+ for (const entry of allowEntries) {
51007
+ const path = pathOfEntry(entry);
51008
+ if (path === void 0) continue;
51009
+ bucketFor(READ_CATEGORY)[path] ??= "allow";
51010
+ if (isRecord$1(entry) && entry[POOL_WRITE_KEY] === true) for (const category of WRITE_CATEGORIES) bucketFor(category)[path] ??= "allow";
51011
+ }
51012
+ return { permission };
51013
+ }
51014
+ //#endregion
49956
51015
  //#region src/features/permissions/qwencode-permissions.ts
49957
51016
  /**
49958
51017
  * Qwen Code uses a settings.json file in `.qwen/` (project) or `~/.qwen/` (global).
@@ -56055,6 +57114,14 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
56055
57114
  supportsImport: true
56056
57115
  }
56057
57116
  }],
57117
+ ["pool", {
57118
+ class: PoolPermissions,
57119
+ meta: {
57120
+ supportsProject: true,
57121
+ supportsGlobal: true,
57122
+ supportsImport: true
57123
+ }
57124
+ }],
56058
57125
  ["qwencode", {
56059
57126
  class: QwencodePermissions,
56060
57127
  meta: {
@@ -56718,8 +57785,19 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
56718
57785
  const changedPaths = [];
56719
57786
  for (const aiDir of aiDirs) {
56720
57787
  const dirPath = aiDir.getDirPath();
56721
- let dirHasChanges = false;
56722
57788
  const mainFile = aiDir.getMainFile();
57789
+ const otherFiles = aiDir.getOtherFiles();
57790
+ const writtenPaths = [
57791
+ dirPath,
57792
+ ...mainFile ? [(0, node_path.join)(dirPath, mainFile.name)] : [],
57793
+ ...otherFiles.map((file) => (0, node_path.join)(dirPath, file.relativeFilePathToDirPath))
57794
+ ];
57795
+ if (await refusesAnyWriteOutsideRoot({
57796
+ logger: this.logger,
57797
+ rootPath: aiDir.getOutputRoot(),
57798
+ targetPaths: writtenPaths
57799
+ })) continue;
57800
+ let dirHasChanges = false;
56723
57801
  let mainFileContent;
56724
57802
  if (mainFile) {
56725
57803
  const mainFilePath = (0, node_path.join)(dirPath, mainFile.name);
@@ -56731,7 +57809,6 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
56731
57809
  existing: existingContent
56732
57810
  })) dirHasChanges = true;
56733
57811
  }
56734
- const otherFiles = aiDir.getOtherFiles();
56735
57812
  for (const file of otherFiles) {
56736
57813
  if (dirHasChanges) break;
56737
57814
  const filePath = (0, node_path.join)(dirPath, file.relativeFilePathToDirPath);
@@ -68850,6 +69927,325 @@ var OpenCodeSubagent = class OpenCodeSubagent extends OpenCodeStyleSubagent {
68850
69927
  }
68851
69928
  };
68852
69929
  //#endregion
69930
+ //#region src/features/subagents/pool-subagent.ts
69931
+ /** How Pool runs an agent; a custom agent defaults to `in_process`. */
69932
+ const POOL_AGENT_TYPES = [
69933
+ "in_process",
69934
+ "command",
69935
+ "agent_server"
69936
+ ];
69937
+ const DEFAULT_POOL_AGENT_TYPE = "in_process";
69938
+ /**
69939
+ * One entry of `subagents.agents.<name>` in Pool's settings file. `type` is
69940
+ * required by Pool and `description` by every enabled custom agent; the
69941
+ * remaining keys depend on the type (`command`/`args`/`env` for `command`,
69942
+ * `agent_server`/`session_config_options` for `agent_server`). Loose so a key
69943
+ * Pool adds later survives the round-trip.
69944
+ * @see https://docs.poolside.ai/subagents
69945
+ * @see https://docs.poolside.ai/settings-file-reference
69946
+ */
69947
+ const PoolAgentSchema = zod_mini.z.looseObject({
69948
+ type: zod_mini.z.optional(zod_mini.z.enum(POOL_AGENT_TYPES)),
69949
+ description: zod_mini.z.optional(zod_mini.z.string()),
69950
+ instructions: zod_mini.z.optional(zod_mini.z.string()),
69951
+ command: zod_mini.z.optional(zod_mini.z.string()),
69952
+ args: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
69953
+ env: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.string())),
69954
+ agent_server: zod_mini.z.optional(zod_mini.z.string()),
69955
+ session_config_options: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.unknown())),
69956
+ inherit_agent_config: zod_mini.z.optional(zod_mini.z.boolean()),
69957
+ disabled: zod_mini.z.optional(zod_mini.z.boolean())
69958
+ });
69959
+ /**
69960
+ * Single spelling of the settings.yaml codec/policy, matching PoolMcp: fail
69961
+ * closed on an unparseable root rather than replacing the user's primary Pool
69962
+ * settings with generated output.
69963
+ */
69964
+ function parsePoolSettings(fileContent, filePath) {
69965
+ return parseSharedConfig({
69966
+ format: "yaml",
69967
+ fileContent,
69968
+ filePath,
69969
+ invalidRootPolicy: "error"
69970
+ });
69971
+ }
69972
+ /**
69973
+ * The file stem an imported agent lands under in `.rulesync/subagents/`. Pool
69974
+ * keys agents by an arbitrary YAML mapping key, so the name is narrowed to a
69975
+ * safe file name before it becomes a path (defense in depth on top of the
69976
+ * central path-traversal guard); the agent keeps its real name in `name`.
69977
+ */
69978
+ function sanitizePoolAgentFileStem(raw) {
69979
+ const stem = raw.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
69980
+ return stem.length > 0 ? stem : "agent";
69981
+ }
69982
+ /**
69983
+ * Read `subagents.agents` out of a parsed settings document. The reserved
69984
+ * `general` agent is Pool's built-in and stays the user's to configure, so it
69985
+ * is never imported; an entry that is not a mapping is skipped.
69986
+ */
69987
+ function agentsOfSettings({ settings, filePath, logger }) {
69988
+ const block = settings[POOL_SUBAGENTS_KEY];
69989
+ const rawAgents = isRecord$1(block) ? block[POOL_SUBAGENTS_AGENTS_KEY] : void 0;
69990
+ const agents = {};
69991
+ if (!isRecord$1(rawAgents)) return agents;
69992
+ for (const [name, raw] of Object.entries(rawAgents)) {
69993
+ if (isPrototypePollutionKey(name) || name === "general") continue;
69994
+ const result = PoolAgentSchema.safeParse(raw);
69995
+ if (!result.success) {
69996
+ logger?.warn(`Skipped Pool subagent ${quoteValueForWarning(name)} in ${filePath}: ${formatError(result.error)}`);
69997
+ continue;
69998
+ }
69999
+ agents[name] = result.data;
70000
+ }
70001
+ return agents;
70002
+ }
70003
+ /**
70004
+ * Pool subagents.
70005
+ *
70006
+ * Pool defines subagents inside its settings file — `.poolside/settings.yaml`
70007
+ * at project scope and `~/.config/poolside/settings.yaml` at user scope —
70008
+ * under `subagents.agents.<name>`, so every targeted rulesync subagent is
70009
+ * collapsed into that one file: the body becomes `instructions`, the shared
70010
+ * `description` is written as-is and the `pool:` frontmatter section supplies
70011
+ * `type` (default `in_process`) and the type-specific keys. rulesync owns the
70012
+ * `agents` map except for Pool's built-in `general` entry, which is carried
70013
+ * over untouched together with `subagents.default` and every other top-level
70014
+ * key of the file (`mcp_servers`, `tools`, `pool`, ...); the file is never
70015
+ * deleted.
70016
+ *
70017
+ * @see https://docs.poolside.ai/subagents
70018
+ * @see https://docs.poolside.ai/settings-file-reference
70019
+ */
70020
+ var PoolSubagent = class extends ToolSubagent {
70021
+ agents;
70022
+ logger;
70023
+ /**
70024
+ * The settings document to write, built lazily: the writer hands over the
70025
+ * existing file through `setFileContent` first, and a file that does not
70026
+ * exist yet gets the block merged into an empty document. Lazy so that a
70027
+ * `forDeletion` instance never consults the gateway.
70028
+ */
70029
+ mergedContent = null;
70030
+ constructor({ agents, logger, ...rest }) {
70031
+ super({
70032
+ ...rest,
70033
+ fileContent: ""
70034
+ });
70035
+ this.agents = agents;
70036
+ this.logger = logger;
70037
+ }
70038
+ getAgents() {
70039
+ return this.agents;
70040
+ }
70041
+ isDeletable() {
70042
+ return false;
70043
+ }
70044
+ shouldMergeExistingFileContent() {
70045
+ return true;
70046
+ }
70047
+ setFileContent(newFileContent) {
70048
+ this.mergedContent = this.mergeInto(newFileContent);
70049
+ }
70050
+ getFileContent() {
70051
+ if (this.mergedContent === null) this.mergedContent = this.mergeInto("");
70052
+ return this.mergedContent;
70053
+ }
70054
+ /**
70055
+ * Rebuild the `subagents` block on top of an existing settings document:
70056
+ * `default` and any other sibling of `agents` are kept, the built-in
70057
+ * `general` agent is carried over, and every other agent is replaced by the
70058
+ * generated set — an agent removed from `.rulesync/subagents/` must not
70059
+ * survive here. A block left empty is retracted rather than written as `{}`.
70060
+ */
70061
+ mergeInto(existingContent) {
70062
+ const paths = {
70063
+ relativeDirPath: this.getRelativeDirPath(),
70064
+ relativeFilePath: this.getRelativeFilePath()
70065
+ };
70066
+ const filePath = (0, node_path.join)(this.getOutputRoot(), paths.relativeDirPath, paths.relativeFilePath);
70067
+ const existing = parsePoolSettings(existingContent, filePath);
70068
+ const existingBlock = isRecord$1(existing["subagents"]) ? existing[POOL_SUBAGENTS_KEY] : {};
70069
+ const block = {};
70070
+ for (const [key, value] of Object.entries(existingBlock)) if (!isPrototypePollutionKey(key) && key !== "agents") block[key] = value;
70071
+ const existingAgents = isRecord$1(existingBlock["agents"]) ? existingBlock[POOL_SUBAGENTS_AGENTS_KEY] : {};
70072
+ const general = existingAgents[POOL_GENERAL_AGENT_NAME];
70073
+ const agents = {
70074
+ ...general !== void 0 ? { [POOL_GENERAL_AGENT_NAME]: general } : {},
70075
+ ...this.agents
70076
+ };
70077
+ const dropped = Object.keys(existingAgents).filter((name) => name !== "general" && !Object.hasOwn(agents, name));
70078
+ if (dropped.length > 0) {
70079
+ const advice = this.global ? "hand-written agents belong in .rulesync/subagents/" : `hand-written agents belong in ${POOL_SETTINGS_LOCAL_FILE_NAME}`;
70080
+ this.logger?.warn(`Removed Pool subagent(s) ${dropped.map(quoteValueForWarning).join(", ")} from ${filePath}: rulesync recomputes "${POOL_SUBAGENTS_KEY}.${POOL_SUBAGENTS_AGENTS_KEY}" from its own subagents on every run, so ${advice}.`);
70081
+ }
70082
+ if (Object.keys(agents).length > 0) block[POOL_SUBAGENTS_AGENTS_KEY] = agents;
70083
+ return applySharedConfigPatch({
70084
+ fileKey: sharedConfigFileKey(paths),
70085
+ feature: "subagents",
70086
+ existingContent,
70087
+ patch: { [POOL_SUBAGENTS_KEY]: Object.keys(block).length > 0 ? block : void 0 },
70088
+ filePath,
70089
+ logger: this.logger
70090
+ });
70091
+ }
70092
+ /**
70093
+ * Every agent lives in the one settings file, which the MCP feature writes
70094
+ * too: naming the file here keeps the gitignore derivation from claiming the
70095
+ * whole directory and lets the shared-file derivation order the writers.
70096
+ */
70097
+ static getSettablePaths({ global = false } = {}) {
70098
+ return {
70099
+ relativeDirPath: global ? POOL_GLOBAL_DIR : POOL_DIR,
70100
+ relativeFilePath: POOL_SETTINGS_FILE_NAME
70101
+ };
70102
+ }
70103
+ /**
70104
+ * Map a single rulesync subagent to a Pool agent entry. The body becomes
70105
+ * `instructions` and the shared `description` is written as-is; the
70106
+ * optional `pool:` section supplies `type` and the type-specific keys and
70107
+ * may override both. Returns `null` for the reserved `general` name, which
70108
+ * Pool defines itself.
70109
+ */
70110
+ static toPoolAgent({ rulesyncSubagent, logger }) {
70111
+ const frontmatter = rulesyncSubagent.getFrontmatter();
70112
+ const name = frontmatter.name;
70113
+ if (name === "general") {
70114
+ logger?.warn(`Pool subagent ${quoteValueForWarning(name)} was skipped: "${POOL_GENERAL_AGENT_NAME}" is Pool's built-in agent, so configure it in settings.yaml directly.`);
70115
+ return null;
70116
+ }
70117
+ if (isPrototypePollutionKey(name)) {
70118
+ logger?.warn(`Pool subagent ${quoteValueForWarning(name)} was skipped: unsafe name.`);
70119
+ return null;
70120
+ }
70121
+ const rawSection = isRecord$1(frontmatter.pool) ? frontmatter.pool : {};
70122
+ const result = PoolAgentSchema.safeParse(rawSection);
70123
+ if (!result.success) throw new Error(`Invalid "pool" section of subagent ${quoteValueForWarning(name)}: ${formatError(result.error)}`);
70124
+ const { type, description, instructions, ...rest } = result.data;
70125
+ const body = rulesyncSubagent.getBody().trim();
70126
+ const effectiveDescription = description ?? frontmatter.description;
70127
+ const effectiveInstructions = instructions ?? (body !== "" ? body : void 0);
70128
+ if (effectiveDescription === void 0 && rest.disabled !== true) logger?.warn(`Pool subagent ${quoteValueForWarning(name)} has no description; Pool requires one for every enabled custom agent.`);
70129
+ return {
70130
+ name,
70131
+ agent: {
70132
+ type: type ?? DEFAULT_POOL_AGENT_TYPE,
70133
+ ...effectiveDescription !== void 0 ? { description: effectiveDescription } : {},
70134
+ ...effectiveInstructions !== void 0 ? { instructions: effectiveInstructions } : {},
70135
+ ...rest
70136
+ }
70137
+ };
70138
+ }
70139
+ /**
70140
+ * Aggregate every targeted rulesync subagent into the one settings file.
70141
+ * Agents are keyed by their `name`; a repeated name keeps the last one, with
70142
+ * a warning, so the map stays what Pool would read.
70143
+ */
70144
+ static fromRulesyncSubagents({ outputRoot = process.cwd(), rulesyncSubagents, validate = true, global = false, logger }) {
70145
+ const agents = {};
70146
+ for (const rulesyncSubagent of rulesyncSubagents) {
70147
+ const converted = this.toPoolAgent({
70148
+ rulesyncSubagent,
70149
+ logger
70150
+ });
70151
+ if (converted === null) continue;
70152
+ if (Object.hasOwn(agents, converted.name)) logger?.warn(`Pool subagent ${quoteValueForWarning(converted.name)} is defined more than once; the last definition (${rulesyncSubagent.getRelativePathFromCwd()}) wins.`);
70153
+ agents[converted.name] = converted.agent;
70154
+ }
70155
+ return new this({
70156
+ outputRoot,
70157
+ relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
70158
+ relativeFilePath: POOL_SETTINGS_FILE_NAME,
70159
+ agents,
70160
+ validate,
70161
+ global,
70162
+ logger
70163
+ });
70164
+ }
70165
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false, logger }) {
70166
+ return this.fromRulesyncSubagents({
70167
+ outputRoot,
70168
+ rulesyncSubagents: [rulesyncSubagent],
70169
+ validate,
70170
+ global,
70171
+ logger
70172
+ });
70173
+ }
70174
+ /**
70175
+ * Convert every custom agent of the settings file back into an individual
70176
+ * rulesync subagent. `instructions` becomes the body, `description` the
70177
+ * shared field, and everything else — `type` only when it is not the
70178
+ * default — rides the `pool:` section so it survives the round-trip.
70179
+ */
70180
+ toRulesyncSubagents() {
70181
+ return Object.entries(this.agents).map(([name, agent]) => {
70182
+ const { type, description, instructions, ...rest } = agent;
70183
+ const poolSection = {
70184
+ ...type !== void 0 && type !== DEFAULT_POOL_AGENT_TYPE ? { type } : {},
70185
+ ...rest
70186
+ };
70187
+ const rulesyncFrontmatter = {
70188
+ targets: ["pool"],
70189
+ name,
70190
+ ...description !== void 0 ? { description } : {},
70191
+ ...Object.keys(poolSection).length > 0 ? { pool: poolSection } : {}
70192
+ };
70193
+ return new RulesyncSubagent({
70194
+ outputRoot: process.cwd(),
70195
+ frontmatter: rulesyncFrontmatter,
70196
+ body: instructions ?? "",
70197
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
70198
+ relativeFilePath: `${sanitizePoolAgentFileStem(name)}.md`,
70199
+ validate: true
70200
+ });
70201
+ });
70202
+ }
70203
+ toRulesyncSubagent() {
70204
+ const first = this.toRulesyncSubagents()[0];
70205
+ if (!first) throw new Error(`No custom subagents found in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())} to convert.`);
70206
+ return first;
70207
+ }
70208
+ validate() {
70209
+ return {
70210
+ success: true,
70211
+ error: null
70212
+ };
70213
+ }
70214
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
70215
+ return this.isTargetedByRulesyncSubagentDefault({
70216
+ rulesyncSubagent,
70217
+ toolTarget: "pool"
70218
+ });
70219
+ }
70220
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false, logger }) {
70221
+ const paths = this.getSettablePaths({ global });
70222
+ const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
70223
+ const settings = parsePoolSettings(await readFileContentOrNull(filePath) ?? "", filePath);
70224
+ return new this({
70225
+ outputRoot,
70226
+ relativeDirPath: paths.relativeDirPath,
70227
+ relativeFilePath,
70228
+ agents: agentsOfSettings({
70229
+ settings,
70230
+ filePath,
70231
+ logger
70232
+ }),
70233
+ validate,
70234
+ global
70235
+ });
70236
+ }
70237
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
70238
+ return new this({
70239
+ outputRoot,
70240
+ relativeDirPath,
70241
+ relativeFilePath,
70242
+ agents: {},
70243
+ validate: false,
70244
+ global
70245
+ });
70246
+ }
70247
+ };
70248
+ //#endregion
68853
70249
  //#region src/features/subagents/reasonix-subagent.ts
68854
70250
  const ReasonixSubagentFrontmatterSchema = zod_mini.z.looseObject({
68855
70251
  name: zod_mini.z.string(),
@@ -70170,6 +71566,16 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
70170
71566
  filePattern: "*.md"
70171
71567
  }
70172
71568
  }],
71569
+ ["pool", {
71570
+ class: PoolSubagent,
71571
+ meta: {
71572
+ supportsProject: true,
71573
+ supportsSimulated: false,
71574
+ supportsGlobal: true,
71575
+ emitsEmptyAggregate: true,
71576
+ filePattern: POOL_SETTINGS_FILE_NAME
71577
+ }
71578
+ }],
70173
71579
  ["qwencode", {
70174
71580
  class: QwencodeSubagent,
70175
71581
  meta: {
@@ -70282,6 +71688,12 @@ var SubagentsProcessor = class extends FeatureProcessor {
70282
71688
  toolTarget;
70283
71689
  global;
70284
71690
  getFactory;
71691
+ /**
71692
+ * Whether the last `loadRulesyncFiles` found a `subagents/` directory under
71693
+ * any input root. A project without one has not adopted the feature, so the
71694
+ * agents in its Pool settings are its own and must not be retracted.
71695
+ */
71696
+ rulesyncSourceDirFound = false;
70285
71697
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$1, dryRun = false, logger }) {
70286
71698
  super({
70287
71699
  outputRoot,
@@ -70295,16 +71707,20 @@ var SubagentsProcessor = class extends FeatureProcessor {
70295
71707
  this.global = global;
70296
71708
  this.getFactory = getFactory;
70297
71709
  }
71710
+ emitsToolFilesForEmptySource() {
71711
+ return this.rulesyncSourceDirFound && this.getFactory(this.toolTarget).meta.emitsEmptyAggregate === true;
71712
+ }
70298
71713
  async convertRulesyncFilesToToolFiles(rulesyncFiles) {
70299
71714
  const rulesyncSubagents = rulesyncFiles.filter((file) => file instanceof RulesyncSubagent);
70300
71715
  const factory = this.getFactory(this.toolTarget);
70301
71716
  const targeted = rulesyncSubagents.filter((rulesyncSubagent) => factory.class.isTargetedByRulesyncSubagent(rulesyncSubagent));
70302
71717
  if (factory.class.fromRulesyncSubagents) {
70303
- if (targeted.length === 0) return [];
71718
+ if (targeted.length === 0 && !this.emitsToolFilesForEmptySource()) return [];
70304
71719
  const toolSubagents = factory.class.fromRulesyncSubagents({
70305
71720
  outputRoot: this.outputRoot,
70306
71721
  rulesyncSubagents: targeted,
70307
- global: this.global
71722
+ global: this.global,
71723
+ logger: this.logger
70308
71724
  });
70309
71725
  return Array.isArray(toolSubagents) ? toolSubagents : [toolSubagents];
70310
71726
  }
@@ -70360,6 +71776,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
70360
71776
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
70361
71777
  return [];
70362
71778
  }
71779
+ this.rulesyncSourceDirFound = true;
70363
71780
  const mdFiles = (await listDirectoryEntryNames(subagentsDir)).filter((file) => file.endsWith(".md"));
70364
71781
  if (mdFiles.length === 0) {
70365
71782
  this.logger.debug(`No markdown files found in rulesync subagents directory: ${subagentsDir}`);
@@ -70400,6 +71817,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
70400
71817
  * earlier root's copy.
70401
71818
  */
70402
71819
  async loadRulesyncFiles() {
71820
+ this.rulesyncSourceDirFound = false;
70403
71821
  const rulesyncSubagents = mergeByCaseInsensitiveIdentity({
70404
71822
  perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
70405
71823
  identity: (subagent) => subagent.getRelativeFilePath(),
@@ -70462,7 +71880,8 @@ var SubagentsProcessor = class extends FeatureProcessor {
70462
71880
  outputRoot: rootOutputRoot,
70463
71881
  relativeDirPath: dirPath,
70464
71882
  relativeFilePath: toRelativeFilePath(path),
70465
- global: this.global
71883
+ global: this.global,
71884
+ logger: this.logger
70466
71885
  })));
70467
71886
  toolSubagents.push(...this.claimStandaloneSubagents({
70468
71887
  loaded,
@@ -74384,22 +75803,30 @@ var DevinRule = class DevinRule extends ToolRule {
74384
75803
  }
74385
75804
  };
74386
75805
  //#endregion
74387
- //#region src/features/rules/dsh-rule.ts
75806
+ //#region src/features/rules/nested-agentsmd-rule.ts
74388
75807
  /**
74389
- * DeepSeek Harness (`dsh`) rules.
75808
+ * Shared adapter for the targets that read instructions exactly the way the
75809
+ * AGENTS.md standard describes them and nothing more: a root `AGENTS.md` at the
75810
+ * project root, nested per-directory `AGENTS.md` files loaded only while working
75811
+ * under that directory, and a single personal root file under a tool-specific
75812
+ * home directory. Such a target has no modular non-root instruction directory,
75813
+ * so every other non-root rule folds into the root file.
74390
75814
  *
74391
- * Project scope writes the root `AGENTS.md` plus nested `<dir>/AGENTS.md`
74392
- * files — the project chain `dsh-agent-instructions` loads from the `.git`
74393
- * root down to the session cwd. Global scope writes the user-global
74394
- * `~/.dsh/AGENTS.md` (`$DSH_HOME` default), which the harness reads as a
74395
- * single root file with no local overlay, so non-root rules fold into it.
75815
+ * Concrete targets (Pool, Vibe, DeepSeek Harness) only supply the global
75816
+ * directory and their tool target through {@link getFamily}; the vendor docs
75817
+ * describing each one's discovery walk live on the subclass.
74396
75818
  *
74397
- * @see https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md
75819
+ * The nested-file scan mirrors the AGENTS.md standard's nested discovery —
75820
+ * same file name, same exclusions, import-only, project scope — because it
75821
+ * discovers literally the same files.
74398
75822
  */
74399
- var DshRule = class DshRule extends ToolRule {
75823
+ var NestedAgentsmdRule = class extends ToolRule {
75824
+ static getFamily() {
75825
+ throw new Error("Please implement this method in the subclass.");
75826
+ }
74400
75827
  static getSettablePaths({ global = false } = {}) {
74401
75828
  if (global) return { root: {
74402
- relativeDirPath: DSH_DIR,
75829
+ relativeDirPath: this.getFamily().globalDir,
74403
75830
  relativeFilePath: AGENTSMD_RULE_FILE_NAME
74404
75831
  } };
74405
75832
  return { root: {
@@ -74407,18 +75834,6 @@ var DshRule = class DshRule extends ToolRule {
74407
75834
  relativeFilePath: AGENTSMD_RULE_FILE_NAME
74408
75835
  } };
74409
75836
  }
74410
- /**
74411
- * DeepSeek Harness loads the project chain of `AGENTS.md` files from the
74412
- * project root (the nearest ancestor holding `.git`) down through the session
74413
- * cwd, broad to specific, so a per-directory file applies only while working
74414
- * under that directory. Nested files are therefore a real scoping surface,
74415
- * not just the root file's overflow.
74416
- *
74417
- * The scan mirrors the AGENTS.md standard's nested discovery — same file
74418
- * name, same exclusions, import-only, project scope — because it discovers
74419
- * literally the same files.
74420
- * @see https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md
74421
- */
74422
75837
  static getNestedFilePatterns() {
74423
75838
  return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
74424
75839
  }
@@ -74433,7 +75848,7 @@ var DshRule = class DshRule extends ToolRule {
74433
75848
  const { root } = this.getSettablePaths({ global });
74434
75849
  if (overrideDirPath !== void 0 && overrideDirPath !== root.relativeDirPath && overrideDirPath !== ".") {
74435
75850
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, overrideDirPath, AGENTSMD_RULE_FILE_NAME));
74436
- return new DshRule({
75851
+ return new this({
74437
75852
  outputRoot,
74438
75853
  relativeDirPath: overrideDirPath,
74439
75854
  relativeFilePath: AGENTSMD_RULE_FILE_NAME,
@@ -74443,7 +75858,7 @@ var DshRule = class DshRule extends ToolRule {
74443
75858
  });
74444
75859
  }
74445
75860
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, root.relativeDirPath, root.relativeFilePath));
74446
- return new DshRule({
75861
+ return new this({
74447
75862
  outputRoot,
74448
75863
  relativeDirPath: root.relativeDirPath,
74449
75864
  relativeFilePath: root.relativeFilePath,
@@ -74457,7 +75872,7 @@ var DshRule = class DshRule extends ToolRule {
74457
75872
  const frontmatter = rulesyncRule.getFrontmatter();
74458
75873
  const isRoot = frontmatter.root ?? false;
74459
75874
  const subprojectPath = frontmatter.agentsmd?.subprojectPath;
74460
- if (!global && !isRoot && subprojectPath) return new DshRule({
75875
+ if (!global && !isRoot && subprojectPath) return new this({
74461
75876
  outputRoot,
74462
75877
  relativeDirPath: (0, node_path.join)(subprojectPath),
74463
75878
  relativeFilePath: AGENTSMD_RULE_FILE_NAME,
@@ -74465,7 +75880,7 @@ var DshRule = class DshRule extends ToolRule {
74465
75880
  validate,
74466
75881
  root: false
74467
75882
  });
74468
- return new DshRule({
75883
+ return new this({
74469
75884
  outputRoot,
74470
75885
  relativeDirPath: root.relativeDirPath,
74471
75886
  relativeFilePath: root.relativeFilePath,
@@ -74486,23 +75901,47 @@ var DshRule = class DshRule extends ToolRule {
74486
75901
  };
74487
75902
  }
74488
75903
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
74489
- return new DshRule({
75904
+ const isRoot = relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === this.getFamily().globalDir);
75905
+ return new this({
74490
75906
  outputRoot,
74491
75907
  relativeDirPath,
74492
75908
  relativeFilePath,
74493
75909
  fileContent: "",
74494
75910
  validate: false,
74495
- root: relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === ".dsh")
75911
+ root: isRoot
74496
75912
  });
74497
75913
  }
74498
75914
  static isTargetedByRulesyncRule(rulesyncRule) {
74499
75915
  return this.isTargetedByRulesyncRuleDefault({
74500
75916
  rulesyncRule,
74501
- toolTarget: "dsh"
75917
+ toolTarget: this.getFamily().toolTarget
74502
75918
  });
74503
75919
  }
74504
75920
  };
74505
75921
  //#endregion
75922
+ //#region src/features/rules/dsh-rule.ts
75923
+ /**
75924
+ * DeepSeek Harness (`dsh`) rules.
75925
+ *
75926
+ * Project scope writes the root `AGENTS.md` plus nested `<dir>/AGENTS.md`
75927
+ * files — the project chain `dsh-agent-instructions` loads from the `.git`
75928
+ * root down to the session cwd, broad to specific, so a per-directory file
75929
+ * applies only while working under that directory. Global scope writes the
75930
+ * user-global `~/.dsh/AGENTS.md` (`$DSH_HOME` default), which the harness
75931
+ * reads as a single root file with no `.local.md` overlay, so non-root rules
75932
+ * fold into it.
75933
+ *
75934
+ * @see https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md
75935
+ */
75936
+ var DshRule = class extends NestedAgentsmdRule {
75937
+ static getFamily() {
75938
+ return {
75939
+ globalDir: DSH_DIR,
75940
+ toolTarget: "dsh"
75941
+ };
75942
+ }
75943
+ };
75944
+ //#endregion
74506
75945
  //#region src/features/rules/factorydroid-rule.ts
74507
75946
  /**
74508
75947
  * Rule generator for Factory Droid.
@@ -75896,110 +77335,24 @@ var PiRule = class PiRule extends ToolRule {
75896
77335
  };
75897
77336
  //#endregion
75898
77337
  //#region src/features/rules/pool-rule.ts
75899
- var PoolRule = class PoolRule extends ToolRule {
75900
- static getSettablePaths({ global = false } = {}) {
75901
- if (global) return { root: {
75902
- relativeDirPath: POOL_GLOBAL_DIR,
75903
- relativeFilePath: AGENTSMD_RULE_FILE_NAME
75904
- } };
75905
- return { root: {
75906
- relativeDirPath: ".",
75907
- relativeFilePath: AGENTSMD_RULE_FILE_NAME
75908
- } };
75909
- }
75910
- /**
75911
- * Pool reads personal, project, and directory-level `AGENTS.md` files: inside
75912
- * a git repository it loads every `AGENTS.md` from the repository root down
75913
- * through the working directory (deeper files take precedence), skipping
75914
- * ignored directories. Nested files are therefore a real scoping surface, not
75915
- * just the root file's overflow.
75916
- *
75917
- * The scan mirrors the AGENTS.md standard's nested discovery — same file
75918
- * name, same exclusions, import-only, project scope — because it discovers
75919
- * literally the same files.
75920
- * @see https://docs.poolside.ai/agent-instructions
75921
- */
75922
- static getNestedFilePatterns() {
75923
- return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
75924
- }
75925
- /**
75926
- * The subproject directory this rule scopes, or `undefined` for the root file
75927
- * (project or global).
75928
- */
75929
- getSubprojectPath() {
75930
- return this.getNestedSubprojectPath({ fileName: AGENTSMD_RULE_FILE_NAME });
75931
- }
75932
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, relativeDirPath: overrideDirPath, validate = true, global = false }) {
75933
- const { root } = this.getSettablePaths({ global });
75934
- if (overrideDirPath !== void 0 && overrideDirPath !== root.relativeDirPath && overrideDirPath !== ".") {
75935
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, overrideDirPath, AGENTSMD_RULE_FILE_NAME));
75936
- return new PoolRule({
75937
- outputRoot,
75938
- relativeDirPath: overrideDirPath,
75939
- relativeFilePath: AGENTSMD_RULE_FILE_NAME,
75940
- fileContent,
75941
- validate,
75942
- root: false
75943
- });
75944
- }
75945
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, root.relativeDirPath, root.relativeFilePath));
75946
- return new PoolRule({
75947
- outputRoot,
75948
- relativeDirPath: root.relativeDirPath,
75949
- relativeFilePath: root.relativeFilePath,
75950
- fileContent,
75951
- validate,
75952
- root: true
75953
- });
75954
- }
75955
- static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
75956
- const { root } = this.getSettablePaths({ global });
75957
- const frontmatter = rulesyncRule.getFrontmatter();
75958
- const isRoot = frontmatter.root ?? false;
75959
- const subprojectPath = frontmatter.agentsmd?.subprojectPath;
75960
- if (!global && !isRoot && subprojectPath) return new PoolRule({
75961
- outputRoot,
75962
- relativeDirPath: (0, node_path.join)(subprojectPath),
75963
- relativeFilePath: AGENTSMD_RULE_FILE_NAME,
75964
- fileContent: rulesyncRule.getBody(),
75965
- validate,
75966
- root: false
75967
- });
75968
- return new PoolRule({
75969
- outputRoot,
75970
- relativeDirPath: root.relativeDirPath,
75971
- relativeFilePath: root.relativeFilePath,
75972
- fileContent: rulesyncRule.getBody(),
75973
- validate,
75974
- root: isRoot
75975
- });
75976
- }
75977
- toRulesyncRule() {
75978
- const subprojectPath = this.getSubprojectPath();
75979
- if (subprojectPath === void 0) return this.toRulesyncRuleDefault();
75980
- return this.toRulesyncRuleNestedAgentsmd({ subprojectPath });
75981
- }
75982
- validate() {
77338
+ /**
77339
+ * Pool (Poolside) rules.
77340
+ *
77341
+ * Pool reads personal, project, and directory-level `AGENTS.md` files: inside
77342
+ * a git repository it loads every `AGENTS.md` from the repository root down
77343
+ * through the working directory (deeper files take precedence), skipping
77344
+ * ignored directories. Nested files are therefore a real scoping surface, not
77345
+ * just the root file's overflow. The personal file sits below both at
77346
+ * `~/.config/poolside/AGENTS.md`.
77347
+ *
77348
+ * @see https://docs.poolside.ai/agent-instructions
77349
+ */
77350
+ var PoolRule = class extends NestedAgentsmdRule {
77351
+ static getFamily() {
75983
77352
  return {
75984
- success: true,
75985
- error: null
75986
- };
75987
- }
75988
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
75989
- return new PoolRule({
75990
- outputRoot,
75991
- relativeDirPath,
75992
- relativeFilePath,
75993
- fileContent: "",
75994
- validate: false,
75995
- root: relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === POOL_GLOBAL_DIR)
75996
- });
75997
- }
75998
- static isTargetedByRulesyncRule(rulesyncRule) {
75999
- return this.isTargetedByRulesyncRuleDefault({
76000
- rulesyncRule,
77353
+ globalDir: POOL_GLOBAL_DIR,
76001
77354
  toolTarget: "pool"
76002
- });
77355
+ };
76003
77356
  }
76004
77357
  };
76005
77358
  //#endregion
@@ -76963,110 +78316,23 @@ var TaktRule = class TaktRule extends ToolRule {
76963
78316
  //#endregion
76964
78317
  //#region src/features/rules/vibe-rule.ts
76965
78318
  const VIBE_GLOBAL_DIR = ".vibe";
76966
- var VibeRule = class VibeRule extends ToolRule {
76967
- static getSettablePaths({ global = false } = {}) {
76968
- if (global) return { root: {
76969
- relativeDirPath: VIBE_GLOBAL_DIR,
76970
- relativeFilePath: AGENTSMD_RULE_FILE_NAME
76971
- } };
76972
- return { root: {
76973
- relativeDirPath: ".",
76974
- relativeFilePath: AGENTSMD_RULE_FILE_NAME
76975
- } };
76976
- }
76977
- /**
76978
- * Vibe's harness manager walks the directories between the workspace root and
76979
- * the file being read and loads every `AGENTS.md` it finds along the way
76980
- * (`find_subdirectory_agents_md`), injecting the result into the `read_file`
76981
- * tool's output. Nested files are therefore a real scoping surface, not just
76982
- * the root file's overflow.
76983
- *
76984
- * The scan mirrors the AGENTS.md standard's nested discovery — same file
76985
- * name, same exclusions, import-only, project scope — because it discovers
76986
- * literally the same files.
76987
- * @see https://github.com/mistralai/mistral-vibe/blob/main/vibe/core/config/harness_files/_harness_manager.py
76988
- */
76989
- static getNestedFilePatterns() {
76990
- return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
76991
- }
76992
- /**
76993
- * The subproject directory this rule scopes, or `undefined` for the root file
76994
- * (project or global).
76995
- */
76996
- getSubprojectPath() {
76997
- return this.getNestedSubprojectPath({ fileName: AGENTSMD_RULE_FILE_NAME });
76998
- }
76999
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, relativeDirPath: overrideDirPath, validate = true, global = false }) {
77000
- const { root } = this.getSettablePaths({ global });
77001
- if (overrideDirPath !== void 0 && overrideDirPath !== root.relativeDirPath && overrideDirPath !== ".") {
77002
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, overrideDirPath, AGENTSMD_RULE_FILE_NAME));
77003
- return new VibeRule({
77004
- outputRoot,
77005
- relativeDirPath: overrideDirPath,
77006
- relativeFilePath: AGENTSMD_RULE_FILE_NAME,
77007
- fileContent,
77008
- validate,
77009
- root: false
77010
- });
77011
- }
77012
- const fileContent = await readFileContent((0, node_path.join)(outputRoot, root.relativeDirPath, root.relativeFilePath));
77013
- return new VibeRule({
77014
- outputRoot,
77015
- relativeDirPath: root.relativeDirPath,
77016
- relativeFilePath: root.relativeFilePath,
77017
- fileContent,
77018
- validate,
77019
- root: true
77020
- });
77021
- }
77022
- static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
77023
- const { root } = this.getSettablePaths({ global });
77024
- const frontmatter = rulesyncRule.getFrontmatter();
77025
- const isRoot = frontmatter.root ?? false;
77026
- const subprojectPath = frontmatter.agentsmd?.subprojectPath;
77027
- if (!global && !isRoot && subprojectPath) return new VibeRule({
77028
- outputRoot,
77029
- relativeDirPath: (0, node_path.join)(subprojectPath),
77030
- relativeFilePath: AGENTSMD_RULE_FILE_NAME,
77031
- fileContent: rulesyncRule.getBody(),
77032
- validate,
77033
- root: false
77034
- });
77035
- return new VibeRule({
77036
- outputRoot,
77037
- relativeDirPath: root.relativeDirPath,
77038
- relativeFilePath: root.relativeFilePath,
77039
- fileContent: rulesyncRule.getBody(),
77040
- validate,
77041
- root: isRoot
77042
- });
77043
- }
77044
- toRulesyncRule() {
77045
- const subprojectPath = this.getSubprojectPath();
77046
- if (subprojectPath === void 0) return this.toRulesyncRuleDefault();
77047
- return this.toRulesyncRuleNestedAgentsmd({ subprojectPath });
77048
- }
77049
- validate() {
78319
+ /**
78320
+ * Mistral Vibe rules.
78321
+ *
78322
+ * Vibe's harness manager walks the directories between the workspace root and
78323
+ * the file being read and loads every `AGENTS.md` it finds along the way
78324
+ * (`find_subdirectory_agents_md`), injecting the result into the `read_file`
78325
+ * tool's output. Nested files are therefore a real scoping surface, not just
78326
+ * the root file's overflow. The personal file is `~/.vibe/AGENTS.md`.
78327
+ *
78328
+ * @see https://github.com/mistralai/mistral-vibe/blob/main/vibe/core/config/harness_files/_harness_manager.py
78329
+ */
78330
+ var VibeRule = class extends NestedAgentsmdRule {
78331
+ static getFamily() {
77050
78332
  return {
77051
- success: true,
77052
- error: null
77053
- };
77054
- }
77055
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
77056
- return new VibeRule({
77057
- outputRoot,
77058
- relativeDirPath,
77059
- relativeFilePath,
77060
- fileContent: "",
77061
- validate: false,
77062
- root: relativeFilePath === "AGENTS.md" && (relativeDirPath === "." || relativeDirPath === ".vibe")
77063
- });
77064
- }
77065
- static isTargetedByRulesyncRule(rulesyncRule) {
77066
- return this.isTargetedByRulesyncRuleDefault({
77067
- rulesyncRule,
78333
+ globalDir: VIBE_GLOBAL_DIR,
77068
78334
  toolTarget: "vibe"
77069
- });
78335
+ };
77070
78336
  }
77071
78337
  };
77072
78338
  //#endregion
@@ -79273,6 +80539,16 @@ async function activateHermesProjectPlugins({ pluginNames, dryRun, logger }) {
79273
80539
  relativeFilePath: HERMESAGENT_CONFIG_FILE_PATH
79274
80540
  });
79275
80541
  const configPath = (0, node_path.join)(configRoot, relativeConfigPath);
80542
+ if (await refusesWriteOutsideRoot({
80543
+ logger,
80544
+ rootPath: configRoot,
80545
+ targetPath: configPath
80546
+ })) return {
80547
+ count: 0,
80548
+ paths: [],
80549
+ hasDiff: false,
80550
+ sourceLoadFailed: false
80551
+ };
79276
80552
  const expectedConfig = mergeEnabledPlugins({
79277
80553
  existingContent: await readFileContentOrNull(configPath) ?? "",
79278
80554
  filePath: configPath,
@@ -79740,7 +81016,8 @@ async function processEmptyFeatureGeneration(params) {
79740
81016
  */
79741
81017
  async function processFeatureWithRulesyncFiles(params) {
79742
81018
  const { config, processor, rulesyncFiles, sweepPlan, skipFilePaths, onToolFiles } = params;
79743
- if (rulesyncFiles.length === 0) return processEmptyFeatureGeneration({
81019
+ const retractsForEmptySource = processor.emitsToolFilesForEmptySource() && !processor.hasRulesyncSourceLoadFailure();
81020
+ if (rulesyncFiles.length === 0 && !retractsForEmptySource) return processEmptyFeatureGeneration({
79744
81021
  config,
79745
81022
  processor,
79746
81023
  sweepPlan,