rulesync 31.0.0 → 33.0.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.
- package/dist/cli/index.cjs +23 -5
- package/dist/cli/index.js +23 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-ORYbMDJK.js → import-5-Uennl1.js} +451 -50
- package/dist/{import-ORYbMDJK.js.map → import-5-Uennl1.js.map} +1 -1
- package/dist/{import-CBvXTNp8.cjs → import-D_SV_C0k.cjs} +467 -66
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as RulesyncHooks, $t as removeFile, A as groupSpellingsByCaseFoldedIdentity, An as RULESYNC_RELATIVE_DIR_PATH, At as applyFileMode, B as QWENCODE_LOCAL_RULE_FILE_NAME, Bn as DEPRECATED_FEATURE_REPLACEMENTS, Bt as getFileSize, C as COMMANDCODE_SETTINGS_LOCAL_FILE_NAME, Cn as RULESYNC_IGNORE_RELATIVE_FILE_PATH, Ct as fallbackLogger, D as FACTORYDROID_DIR, Dn as RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH, Dt as withWarnOnceScope, E as ChecksProcessor, En as RULESYNC_MCP_SCHEMA_URL, Et as resetRunWarningState, F as CLAUDECODE_LOCAL_RULE_FILE_NAME, Fn as RULESYNC_USER_CONFIG_DIR_NAME, Ft as createTempDirectory, G as RulesyncSubagentFrontmatterSchema, Gn as quoteForLog, Gt as listDirectoryEntryNames, H as AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME, Hn as truncateText, Ht as isFileNotFoundError, I as CLAUDECODE_MEMORIES_DIR_NAME, In as RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME, It as directoryExists, J as RulesyncRule, Jn as stripHiddenCharacters, Jt as pathEscapesRoot, K as RulesyncSkill, Kn as stripControlCharacters, Kt as listFilePathsRecursively, L as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, Ln as parseCommaSeparatedList, Lt as ensureDir, M as CODEXCLI_DIR, Mn as RULESYNC_SKILLS_RELATIVE_DIR_PATH, Mt as assertTreeContainsNoSymlinks, N as CLAUDECODE_AGENT_MEMORY_LOCAL_DIR_NAME, Nn as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, Nt as assertWritablePathInsideRoot, O as FACTORYDROID_SETTINGS_LOCAL_FILE_NAME, On as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, Ot as CLIError, P as CLAUDECODE_DIR, Pn as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, Pt as checkPathTraversal, Q as RulesyncIgnore, Qt as removeDirectoryStrict, R as CLAUDECODE_SKILLS_DIR_PATH, Rn as ALL_FEATURES, Rt as fileExists, S as COMMANDCODE_DIR, Sn as RULESYNC_HOOKS_RELATIVE_FILE_PATH, St as WarningCollectingLogger, T as CODEBUDDY_LOCAL_RULE_FILE_NAME, Tn as RULESYNC_MCP_RELATIVE_FILE_PATH, Tt as withFallbackLoggerTarget, U as getLocalSkillDirNames, Un as hasDeceptiveHiddenCharacters, Ut as isFileSystemError, V as AUGMENTCODE_DIR, Vn as formatError, Vt as getHomeDirectory, W as RulesyncSubagent, Wn as hasEnclosingMarkOutsideKeycap, Wt as isSymlink, X as RulesyncPermissions, Xt as readFileContentOrNull, Y as RulesyncRuleFrontmatterSchema, Yt as readFileContent, Z as RulesyncMcp, Zt as removeDirectory, _ as LETTACODE_DIR, _n as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, _t as SourceEntrySchema, a as getProcessorRegistryEntry, an as toPosixPath, at as RulesyncCheck, b as CommandsProcessor, bn as RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, bt as ConsoleLogger, c as RulesProcessor, cn as ALL_TOOL_TARGETS, ct as loadYaml, d as ELLIPSIS_WIDTH, dn as ToolTargetSchema, dt as ConfigResolver, en as removeFileStrict, et as getRulesyncSourceCandidates, f as displayWidthOf, fn as CURATED_RULES_FEATURE_SUBDIR, ft as mergeInputRootConfigs, g as HooksProcessor, gn as RULESYNC_CHECKS_RELATIVE_DIR_PATH, gt as GITIGNORE_DESTINATION_KEY, h as IgnoreProcessor, hn as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, ht as ConfigFileSchema, i as inspectInputRoots, in as splitPathSegments, it as RulesyncCommandFrontmatterSchema, j as CODEXCLI_BASH_RULES_FILE_NAME, jn as RULESYNC_RULES_RELATIVE_DIR_PATH, jt as assertDirectoryIfExists, k as caseFoldIdentity, kn as RULESYNC_PERMISSIONS_SCHEMA_URL, kt as ErrorCodes, l as SubagentsProcessor, ln as ALL_TOOL_TARGETS_WITH_WILDCARD, lt as SHARED_USER_MANAGED_CONFIG_PATHS, m as McpProcessor, mn as MAX_FILE_SIZE, mt as CONFLICTING_TARGET_PAIRS, n as formatSourceLoadFailure, nn as resolvePath, nt as parseJsonc, o as convertFromTool, on as writeFileBuffer, ot as RulesyncCheckFrontmatterSchema, p as shortenToWidth, pn as FEATURE_SOURCE_TREE_ENTRIES, pt as resolveEffectiveInputRoots, q as RulesyncSkillFrontmatterSchema, qn as stripControlCharactersKeepingLineFeeds, qt as listSubdirectoryNames, r as generate, rn as runWithDirectoryRollback, rt as RulesyncCommand, s as isPackagingToolTarget, sn as writeFileContent, st as stringifyFrontmatter, t as importFromTool, tn as removeTempDirectory, tt as resolveRulesyncSourceWritePath, u as SkillsProcessor, un as PACKAGING_TOOL_TARGETS, ut as SKILL_FILE_NAME$1, v as LETTACODE_SETTINGS_LOCAL_FILE_NAME, vn as RULESYNC_CONFIG_RELATIVE_FILE_PATH, vt as assertTargetsFeaturesExclusive, w as CODEBUDDY_DIR, wn as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, wt as warnOnConflictingFlags, x as REASONIX_LOCAL_RULE_FILE_NAME, xn as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, xt as JsonLogger, y as CRUSH_LOCAL_RULE_FILE_NAME, yn as RULESYNC_CONFIG_SCHEMA_URL, yt as findControlCharacter, z as QWENCODE_DIR, zn as ALL_FEATURES_WITH_WILDCARD, zt as findFilesByGlobs } from "../import-
|
|
2
|
+
import { $ as RulesyncHooks, $t as removeFile, A as groupSpellingsByCaseFoldedIdentity, An as RULESYNC_RELATIVE_DIR_PATH, At as applyFileMode, B as QWENCODE_LOCAL_RULE_FILE_NAME, Bn as DEPRECATED_FEATURE_REPLACEMENTS, Bt as getFileSize, C as COMMANDCODE_SETTINGS_LOCAL_FILE_NAME, Cn as RULESYNC_IGNORE_RELATIVE_FILE_PATH, Ct as fallbackLogger, D as FACTORYDROID_DIR, Dn as RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH, Dt as withWarnOnceScope, E as ChecksProcessor, En as RULESYNC_MCP_SCHEMA_URL, Et as resetRunWarningState, F as CLAUDECODE_LOCAL_RULE_FILE_NAME, Fn as RULESYNC_USER_CONFIG_DIR_NAME, Ft as createTempDirectory, G as RulesyncSubagentFrontmatterSchema, Gn as quoteForLog, Gt as listDirectoryEntryNames, H as AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME, Hn as truncateText, Ht as isFileNotFoundError, I as CLAUDECODE_MEMORIES_DIR_NAME, In as RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME, It as directoryExists, J as RulesyncRule, Jn as stripHiddenCharacters, Jt as pathEscapesRoot, K as RulesyncSkill, Kn as stripControlCharacters, Kt as listFilePathsRecursively, L as CLAUDECODE_SETTINGS_LOCAL_FILE_NAME, Ln as parseCommaSeparatedList, Lt as ensureDir, M as CODEXCLI_DIR, Mn as RULESYNC_SKILLS_RELATIVE_DIR_PATH, Mt as assertTreeContainsNoSymlinks, N as CLAUDECODE_AGENT_MEMORY_LOCAL_DIR_NAME, Nn as RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH, Nt as assertWritablePathInsideRoot, O as FACTORYDROID_SETTINGS_LOCAL_FILE_NAME, On as RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, Ot as CLIError, P as CLAUDECODE_DIR, Pn as RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, Pt as checkPathTraversal, Q as RulesyncIgnore, Qt as removeDirectoryStrict, R as CLAUDECODE_SKILLS_DIR_PATH, Rn as ALL_FEATURES, Rt as fileExists, S as COMMANDCODE_DIR, Sn as RULESYNC_HOOKS_RELATIVE_FILE_PATH, St as WarningCollectingLogger, T as CODEBUDDY_LOCAL_RULE_FILE_NAME, Tn as RULESYNC_MCP_RELATIVE_FILE_PATH, Tt as withFallbackLoggerTarget, U as getLocalSkillDirNames, Un as hasDeceptiveHiddenCharacters, Ut as isFileSystemError, V as AUGMENTCODE_DIR, Vn as formatError, Vt as getHomeDirectory, W as RulesyncSubagent, Wn as hasEnclosingMarkOutsideKeycap, Wt as isSymlink, X as RulesyncPermissions, Xt as readFileContentOrNull, Y as RulesyncRuleFrontmatterSchema, Yt as readFileContent, Z as RulesyncMcp, Zt as removeDirectory, _ as LETTACODE_DIR, _n as RULESYNC_COMMANDS_RELATIVE_DIR_PATH, _t as SourceEntrySchema, a as getProcessorRegistryEntry, an as toPosixPath, at as RulesyncCheck, b as CommandsProcessor, bn as RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH, bt as ConsoleLogger, c as RulesProcessor, cn as ALL_TOOL_TARGETS, ct as loadYaml, d as ELLIPSIS_WIDTH, dn as ToolTargetSchema, dt as ConfigResolver, en as removeFileStrict, et as getRulesyncSourceCandidates, f as displayWidthOf, fn as CURATED_RULES_FEATURE_SUBDIR, ft as mergeInputRootConfigs, g as HooksProcessor, gn as RULESYNC_CHECKS_RELATIVE_DIR_PATH, gt as GITIGNORE_DESTINATION_KEY, h as IgnoreProcessor, hn as RULESYNC_AIIGNORE_RELATIVE_FILE_PATH, ht as ConfigFileSchema, i as inspectInputRoots, in as splitPathSegments, it as RulesyncCommandFrontmatterSchema, j as CODEXCLI_BASH_RULES_FILE_NAME, jn as RULESYNC_RULES_RELATIVE_DIR_PATH, jt as assertDirectoryIfExists, k as caseFoldIdentity, kn as RULESYNC_PERMISSIONS_SCHEMA_URL, kt as ErrorCodes, l as SubagentsProcessor, ln as ALL_TOOL_TARGETS_WITH_WILDCARD, lt as SHARED_USER_MANAGED_CONFIG_PATHS, m as McpProcessor, mn as MAX_FILE_SIZE, mt as CONFLICTING_TARGET_PAIRS, n as formatSourceLoadFailure, nn as resolvePath, nt as parseJsonc, o as convertFromTool, on as writeFileBuffer, ot as RulesyncCheckFrontmatterSchema, p as shortenToWidth, pn as FEATURE_SOURCE_TREE_ENTRIES, pt as resolveEffectiveInputRoots, q as RulesyncSkillFrontmatterSchema, qn as stripControlCharactersKeepingLineFeeds, qt as listSubdirectoryNames, r as generate, rn as runWithDirectoryRollback, rt as RulesyncCommand, s as isPackagingToolTarget, sn as writeFileContent, st as stringifyFrontmatter, t as importFromTool, tn as removeTempDirectory, tt as resolveRulesyncSourceWritePath, u as SkillsProcessor, un as PACKAGING_TOOL_TARGETS, ut as SKILL_FILE_NAME$1, v as LETTACODE_SETTINGS_LOCAL_FILE_NAME, vn as RULESYNC_CONFIG_RELATIVE_FILE_PATH, vt as assertTargetsFeaturesExclusive, w as CODEBUDDY_DIR, wn as RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, wt as warnOnConflictingFlags, x as REASONIX_LOCAL_RULE_FILE_NAME, xn as RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH, xt as JsonLogger, y as CRUSH_LOCAL_RULE_FILE_NAME, yn as RULESYNC_CONFIG_SCHEMA_URL, yt as findControlCharacter, z as QWENCODE_DIR, zn as ALL_FEATURES_WITH_WILDCARD, zt as findFilesByGlobs } from "../import-5-Uennl1.js";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { nonnegative, optional, refine, z } from "zod/mini";
|
|
5
5
|
import { cp, lstat, mkdtemp, readdir, realpath, rm, rmdir, stat } from "node:fs/promises";
|
|
@@ -81,8 +81,14 @@ function parseUrl(url) {
|
|
|
81
81
|
const owner = segments[0];
|
|
82
82
|
const repo = segments[1]?.replace(/\.git$/, "");
|
|
83
83
|
if (segments.length > 2 && (segments[2] === "tree" || segments[2] === "blob")) {
|
|
84
|
-
const
|
|
85
|
-
|
|
84
|
+
const decode = (segment) => decodeUrlSegment({
|
|
85
|
+
segment,
|
|
86
|
+
provider,
|
|
87
|
+
url
|
|
88
|
+
});
|
|
89
|
+
const ref = segments[3] === void 0 ? void 0 : decode(segments[3]);
|
|
90
|
+
const path = segments.length > 4 ? decode(segments.slice(4).join("/")) : void 0;
|
|
91
|
+
if (path !== void 0 && path.split(/[\\/]/).includes("..")) throw new Error(`Invalid ${provider} URL: ${url}. Path must not contain ".." segments.`);
|
|
86
92
|
return {
|
|
87
93
|
provider,
|
|
88
94
|
owner: owner ?? "",
|
|
@@ -98,6 +104,18 @@ function parseUrl(url) {
|
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
/**
|
|
107
|
+
* Percent-decode a URL path segment, turning a malformed escape (e.g. a lone
|
|
108
|
+
* `%`) into an error that names the offending source.
|
|
109
|
+
*/
|
|
110
|
+
function decodeUrlSegment({ segment, provider, url }) {
|
|
111
|
+
try {
|
|
112
|
+
return decodeURIComponent(segment);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof URIError) throw new Error(`Invalid ${provider} URL: ${url}. Malformed percent-encoding in ref or path.`, { cause: error });
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
101
119
|
* Parse shorthand format (without provider prefix)
|
|
102
120
|
*/
|
|
103
121
|
function parseShorthand(source) {
|
|
@@ -4322,7 +4340,7 @@ const DOCS_CONTENT = {
|
|
|
4322
4340
|
"guide/why-rulesync": "# Why Rulesync?\n\n## Single Source of Truth\n\nAuthor rules once, generate everywhere. Rulesync turns a unified ruleset into tool-native formats so teams stop duplicating instructions across multiple AI assistants.\n\n## Tool Freedom Without Friction\n\nLet developers pick the assistant that fits their flow—Copilot, Cursor, Cline, Claude Code, and more—without rewriting team standards.\n\n## Clean, Auditable Outputs\n\nRulesync emits plain configuration files you can commit, review, and ship. If you ever uninstall Rulesync, your generated files keep working.\n\n## Fast Onboarding & Consistency\n\nNew team members get the same conventions, context, and guardrails immediately, keeping code style and quality consistent across tools.\n\n## Multi-Tool & Modular Workflows\n\nCompose rules, MCP configs, commands, and subagents for different tools or scopes (project vs. global) without fragmenting your workflow.\n\n## Ready for What's Next\n\nAI tool ecosystems evolve quickly. Rulesync helps you add, switch, or retire tools while keeping your rules intact.\n",
|
|
4323
4341
|
"reference/cli-commands": "# CLI Commands\n\n## Quick Commands\n\n```bash\n# Initialize new project (recommended: organized rules structure)\nrulesync init\n\n# Import existing configurations (to .rulesync/rules/ by default)\nrulesync import --targets claudecode --features rules,mcp,commands,subagents,skills,permissions\n\n# Import components from an existing plugin directory\nrulesync import --targets claudecode-plugin --features skills,hooks --output-root ./plugins/review-tools\n\n# Convert configurations from one tool to other tools (skips .rulesync/)\nrulesync convert --from cursor --to copilot,claudecode\nrulesync convert --from cursor --to copilot,claudecode --features rules,mcp\n\n# Fetch configurations from a Git repository\nrulesync fetch owner/repo\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\nrulesync fetch https://github.com/owner/repo --conflict skip\n\n# Generate all features for all tools (new preferred syntax)\nrulesync generate --targets \"*\" --features \"*\"\n\n# Generate specific features for specific tools\nrulesync generate --targets copilot,cursor,cline --features rules,mcp\nrulesync generate --targets claudecode --features rules,subagents\n\n# Generate components inside an existing plugin directory\nrulesync generate --targets antigravity-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets augmentcode-plugin --features rules,mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets zcode-plugin --features mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets vibe-plugin --features mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets devin-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets kimi-code-plugin --features rules,commands,subagents,skills --output-roots ./plugins/review-tools\n\n# Generate only rules (no MCP, permissions, commands, or subagents)\nrulesync generate --targets \"*\" --features rules\n\n# Generate simulated commands and subagents\nrulesync generate --targets copilot,cursor,codexcli --features commands,subagents --simulate-commands --simulate-subagents\n\n# Dry run: show changes without writing files\nrulesync generate --dry-run --targets claudecode --features rules\n\n# Check if files are up to date (for CI/CD pipelines)\nrulesync generate --check --targets \"*\" --features \"*\"\n\n# Generate from a shared source tree (without cd-ing into it)\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets \"*\" --features rules\n\n# Install rules and skills from declarative sources in rulesync.jsonc\nrulesync install\n\n# Add a source to rulesync.jsonc, update the lockfile, and install it\nrulesync add anthropics/skills --skills skill-creator\n\n# Add a rule source without selecting skills\nrulesync add acme/ai-standards --rules testing-guidelines\n\n# Force re-resolve all source refs (ignore lockfile)\nrulesync install --update\n\n# Fail if lockfile is missing or out of sync (for CI); fetch missing artifacts using locked refs\nrulesync install --frozen\n\n# Report sources whose lockfile entry is behind the remote, without writing anything\n# (exit 1 if any is behind or not locked, 2 if a source cannot be resolved)\nrulesync install --outdated\n\n# Install then generate (typical workflow)\nrulesync install && rulesync generate\n\n# Add generated files to .gitignore\nrulesync gitignore\n\n# Add only specific tool entries to .gitignore\nrulesync gitignore --targets claudecode,copilot\n\n# Add only specific feature entries to .gitignore\nrulesync gitignore --targets copilot --features rules,commands\n\n# Diagnose the configuration files for common problems (read-only)\nrulesync doctor\n\n# Diagnose and fail CI on warnings too\nrulesync doctor --strict\n\n# Print GitHub release notes for a repository (latest 10 by default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Print the most recent 5 releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Update rulesync to the latest version (single-binary installs)\nrulesync update\n\n# Check for updates without installing\nrulesync update --check\n\n# Force update even if already at latest version\nrulesync update --force\n```\n\n> **Deprecated feature:** `ignore` remains available to existing projects throughout Rulesync 14.x, but new projects should use `permissions`. Any removal will be decided separately and will not occur before a future major release.\n\n## JSON Output\n\nThe global `--json` flag makes a command print a single result document and nothing else. Because that document is the whole of the output, warnings that would otherwise go to standard error are carried inside it, as a top-level `warnings` array of strings:\n\n```json\n{\n \"success\": true,\n \"timestamp\": \"2025-01-01T00:00:00.000Z\",\n \"command\": \"import\",\n \"version\": \"x.y.z\",\n \"warnings\": [\".factory/settings.local.json is a machine-local overrides file …\"],\n \"data\": { \"…\": \"…\" }\n}\n```\n\nThe key is omitted when nothing warned, and `--silent` suppresses warnings there as it does on the console. `warnings` sits beside `data` rather than inside it so a command's own captured keys can never collide with it, and it is reported on the failure document too, where a diagnostic about the input is often what explains the failure.\n\nAt most 100 warnings are reported, each truncated to 1,000 characters and 8,000 characters in total; a run that exceeds any of those limits says so in a final entry rather than growing the document without bound.\n\nBecause that budget is finite, the array carries the diagnostics a run has no other way to report — not a restatement of what `data` already holds. A command that lists something under a captured key writes the list there and warns only about what the list does not say.\n\n## Init Command\n\n`rulesync init` creates the `.rulesync/` directory with sample rule, MCP, subagent, skill, hooks and permissions files, plus a `rulesync.jsonc` configuration file. Existing files are never overwritten.\n\n### User-level defaults\n\nThe generated `rulesync.jsonc` starts from `\"targets\": [\"codexcli\", \"claudecode\", \"opencode\"]` and `\"features\": [\"rules\", \"mcp\", \"subagents\", \"skills\", \"hooks\", \"permissions\"]`. To seed every new project with your own set instead, put a `rulesync.jsonc` in the user config directory: `$XDG_CONFIG_HOME/rulesync/rulesync.jsonc`, which is `~/.config/rulesync/rulesync.jsonc` when `XDG_CONFIG_HOME` is unset (a relative `XDG_CONFIG_HOME` is ignored, as the XDG specification requires).\n\n```jsonc\n// ~/.config/rulesync/rulesync.jsonc\n{\n \"targets\": [\"claudecode\", \"cursor\"],\n \"features\": [\"rules\", \"skills\"],\n}\n```\n\n`init` copies only `targets` and `features` from that file; a key it omits keeps the built-in default, and the object form of `targets` is copied as-is (the top-level `features` key is then left out, since the per-target features live inside the object). Every other key is ignored: the file is a template for `init`, not a configuration layer — `generate` and the other commands read only the project's `rulesync.jsonc` and `rulesync.local.jsonc`. A user config that exists but fails to parse or schema validation (for example, an unknown target name in the array form of `targets`) makes `init` fail rather than silently writing the defaults. The command reports `Seeded targets and features from <path>` when the file was used (`seededFrom` in `--json` output).\n\n## Generate Command\n\nThe `generate` command reads source files from one or more rulesync source trees (default: `<cwd>/.rulesync`; configurable via `--input-roots`) and writes AI tool configuration files to the output directories.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |\n| `--targets, -t <tools>` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` |\n| `--features, -f <features>` | Comma-separated list of features (rules, commands, subagents, skills, mcp, hooks, permissions, checks; deprecated: ignore) | From `rulesync.jsonc` |\n| `--input-roots <paths...>` | Ordered list of rulesync source-tree directories (e.g. `.rulesync`, `.rulesync.local`). Each entry is a source tree itself — no `.rulesync/` join is applied. The first root is required; later roots are optional overlays and may be absent. Later entries override earlier ones for the same relative source path (currently `generate` only). Cannot be combined with `--input-root`. | `<cwd>/.rulesync` |\n| `--input-root <path>` | **Deprecated.** Path to the PARENT directory of a `.rulesync/` source tree; kept for backward compatibility and expands internally to `--input-roots <path>/.rulesync`. Prefer `--input-roots`. Cannot be combined with it. | CWD |\n| `--dry-run` | Show what would change without writing files | `false` |\n| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` |\n| `--global` | Generate for global (user-scope) configuration files | `false` |\n| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` |\n| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` |\n| `--simulate-skills` | Generate simulated skills for tools that do not support them natively | `false` |\n| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` |\n| `--retire-targets <tools>` | Comma-separated list of tools the project no longer uses, or no longer uses the listed features for; deletes the outputs Rulesync manages for them (requires `--features`; see below) | - |\n| `--watch, -w` | Keep running and regenerate whenever rulesync source files change | `false` |\n| `--config, -c <path>` | Path to the configuration file to load (`rulesync.local.jsonc` next to it still overlays it) | `rulesync.jsonc` |\n\n> **Note on `--check` and shared root files:** A root file that several targets\n> write, such as `AGENTS.md`, is compared against its owner's output. The owner\n> is the last target in `rulesync.jsonc` that generates rules, even when\n> `--targets` selects a different target, and its output is built as\n> `rulesync generate` with the same options but without `--targets` would\n> build it. See\n> [Target Order and File Conflicts](../guide/configuration.md#target-order-and-file-conflicts).\n\n> **Retiring a target:** Leaving a tool out of `targets` (or out of\n> `--targets`) never deletes its files — not even with `--delete` — because an\n> omitted target only means \"not processed in this run\". To remove the outputs\n> of a tool the project has dropped, name it and the features Rulesync\n> generated for it explicitly:\n>\n> ```bash\n> # rulesync.jsonc no longer lists cursor; Rulesync generated its rules and mcp\n> rulesync generate --retire-targets cursor --features rules,mcp --dry-run # preview\n> rulesync generate --retire-targets cursor --features rules,mcp\n> ```\n>\n> For each listed feature, Rulesync deletes the files it would sweep with\n> `--delete` for that tool — the same managed paths, nothing else. Note that\n> `--features` scopes the whole run, not only the retirement — except with the\n> object form of `targets`, or when a retired tool is still configured (see\n> below), where the run keeps each target's configured features and\n> `--features` only names what to retire. The listed features apply to every\n> retired tool, and a managed path is deleted even if you edited it by hand,\n> so preview with `--dry-run` first.\n>\n> The same option retires a single feature of a tool the project keeps. Remove\n> the feature from that tool's entry (or, with the array form of `targets`,\n> from `features`, which drops it for every tool) and name the tool and the\n> feature:\n>\n> ```bash\n> # rulesync.jsonc: { \"targets\": { \"claudecode\": [\"rules\"] } } — mcp was removed\n> rulesync generate --retire-targets claudecode --features mcp --dry-run\n> rulesync generate --retire-targets claudecode --features mcp\n> ```\n>\n> A tool that is still configured may only retire features it no longer\n> generates; naming one it still generates is an error.\n>\n> - Unrelated files beside the managed paths stay, a shared file another tool\n> merges into (such as `.claude/settings.json`) is never removed, and a\n> hand-written legacy root Rulesync only reads (such as `.claude/CLAUDE.md`)\n> is kept.\n> - A path a configured target writes in the same run (for example\n> `AGENTS.md` written by `codexcli` while `agentsmd` is retired) is kept.\n> - Retirement is idempotent, so an interrupted run can simply be repeated.\n> `--check` fails while retired outputs remain, and `--json` lists the\n> retired paths among the deletions.\n> - The option is CLI-only on purpose: retirement is a one-off migration step,\n> not a standing setting. It requires `--features` (without `*`), and cannot\n> be combined with `--targets` or `--watch`. A retired target may appear in\n> `targets` only if none of the listed features is configured for it.\n> - Retirement is skipped with a warning in global mode, when the\n> configuration file declares targets the run does not cover, and when any\n> `.rulesync` source file could not be read.\n> - With the object form of `outputRoots`, keep an entry for the retired\n> target so its outputs can be located.\n\n> **Note on `--delete` and shared output directories:** Several targets write\n> into one directory on purpose — `.agents/agents/`, `.agents/skills/`, and the\n> rest of the cross-vendor roots. The orphan sweep runs only after every target\n> and every feature in the run has written, and it skips any path the run itself\n> produced, so one target never deletes a sibling's freshly written file and an\n> already-synchronized tree stays a no-op under `--check`. What is swept is\n> unchanged: a file in a generated directory that no `.rulesync/` source\n> produces.\n>\n> Takt's skills are the one target swept by file name rather than by directory\n> (its rules, commands, and subagents are swept normally). They are flat files\n> sharing a single `.takt/facets/knowledge/` root rather than each getting a\n> directory of its own, so what the sweep removes there is a `.md` file directly\n> under that root which no `.rulesync/skills/` source produces. The root itself\n> and anything nested inside it are left alone. See [Takt](../tools/takt.md).\n>\n> Skill directories are swept from the inside as well, not only as whole\n> directories. Deleting a supporting file from `.rulesync/skills/<name>/` while\n> keeping the skill removes the generated copy on the next `generate --delete`,\n> and `generate --check` reports such a leftover as out of date rather than\n> passing — which matters most in a pre-commit hook, where an incremental\n> generate would otherwise commit a stale copy of instructions an agent still\n> reads. Two kinds of file inside a generated skill directory are never swept:\n> **hidden entries** and **symbolic links**. A symbolic link is never\n> Rulesync's, since the writer only ever creates real files. A hidden file may\n> be — a hidden supporting file such as a `.env.example` is carried like any\n> other — but a hidden name is also where your own files live (a `.gitkeep`, a\n> `.env` with real values in it), and the sweep cannot tell the two apart, so a\n> stale hidden supporting file is the one leftover it knowingly keeps: delete\n> it by hand. A generated skill\n> directory that is itself a symbolic link — to a vendored checkout, say — is\n> not swept from the inside either: what it reads back was never Rulesync's\n> output, and the run says so with a warning. Only files are swept, so a\n> directory left empty by the sweep stays, and a run that could not read every\n> supporting file its sources carry — one it lacked permission to open, or a\n> tree past the limits a skill directory is walked to, each reported as a\n> warning — sweeps no skill directory from the inside at all: with the source\n> only partly read, a generated file it cannot account for may be one it still\n> wants.\n>\n> An output directory reached through a symbolic link that leads outside the\n> output root — a checked-out `.cursor/commands -> ~/notes`, say — is not swept\n> either: what the sweep would list there are files in the link's target, not\n> Rulesync's output, so each one is kept, and every directory holding them is\n> named once in a `Refusing to delete files in` warning, the same way a write\n> through such a link is refused. A link that stays\n> inside the output root is followed as usual, and an orphan that is itself a\n> symbolic link is removed as a link, leaving its target untouched. A generated\n> skill directory linked onto the output root itself is not written either, so\n> its `SKILL.md` never overwrites a file in the root; and only a skill directory\n> that lands directly below its own skills directory counts as the run's output\n> as a whole, so one linked to an ancestor such as `.claude` does not keep the\n> orphans under that ancestor from being swept.\n\n> **Note on unreadable sources:** This applies to the single-file features —\n> `mcp`, `hooks`, `permissions`, and `ignore` — each of which is generated from\n> one `.rulesync/` file. When that file exists but cannot be read — malformed\n> JSON/JSONC, or content the schema rejects — the problem is reported as an\n> error, the feature produces no output, and `generate` exits non-zero, naming\n> the affected features. Every other feature in the run still executes first, so\n> one bad file does not hide the rest of the errors. Because that feature's\n> output could not be regenerated, `--delete` also skips its orphan sweep,\n> leaving the previously generated files in place rather than deleting\n> configuration the run was unable to rewrite. Under `--watch` the failure is\n> reported and the watcher keeps running, so saving a corrected source\n> regenerates as usual.\n>\n> A source file that is simply absent is not an error: that feature just has\n> nothing to generate, and the run succeeds — it is still logged, but it does\n> not fail the run. Only genuine absence counts; a path that cannot even be\n> checked (a permission error, a symlink loop, a symlink whose target is gone)\n> is treated as unreadable, not as missing.\n>\n> The directory-based features handle an unparseable source file differently,\n> and not uniformly. `subagents` and `checks` report it as a warning and skip\n> that file, because their directories hold free-form Markdown that users also\n> keep notes and READMEs in; the rest of the directory still generates and the\n> run succeeds. `rules`, `commands`, and `skills` do not: an invalid frontmatter\n> there aborts `generate` with that error and a non-zero exit, without the\n> per-feature isolation described above.\n>\n> A source entry that exists but cannot be _read_ is never treated as deleted\n> for any of them, warn-and-skip features included: a `.rulesync/` file or\n> directory whose symbolic link no longer resolves, or an input root that\n> cannot be resolved, stops the run rather than letting `--delete` sweep away\n> what it was supposed to generate.\n\n### Examples\n\n```bash\n# Generate all features for all configured tools\nrulesync generate\n\n# Generate rules for all tools\nrulesync generate --targets \"*\" --features rules\n\n# Generate from a shared source tree without cd-ing into it\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets \"*\" --features rules\n\n# Dry run: preview changes without writing\nrulesync generate --dry-run --targets claudecode --features rules\n\n# CI check: fail if generated files are not up to date\nrulesync generate --check --targets \"*\" --features \"*\"\n\n# Watch mode: regenerate on every change to the sources\nrulesync generate --watch\n```\n\n### Mutation plan in JSON output\n\nWith the global `--json` flag, `generate` reports the files it wrote, the managed keys of shared configuration files it changed, and the orphans it deleted — or, under `--dry-run` and `--check`, would write, change and delete — as a versioned `plan`, next to the existing per-feature `features` summary:\n\n```json\n{\n \"success\": true,\n \"command\": \"generate\",\n \"data\": {\n \"features\": { \"…\": \"…\" },\n \"plan\": {\n \"version\": 2,\n \"operations\": [\n { \"action\": \"write\", \"kind\": \"file\", \"feature\": \"mcp\", \"path\": \".codex/config.toml\" },\n {\n \"action\": \"delete\",\n \"kind\": \"key\",\n \"feature\": \"mcp\",\n \"path\": \".codex/config.toml\",\n \"key\": \"mcp_servers\"\n },\n { \"action\": \"write\", \"kind\": \"file\", \"feature\": \"rules\", \"path\": \"CLAUDE.md\" },\n {\n \"action\": \"delete\",\n \"kind\": \"file\",\n \"feature\": \"rules\",\n \"path\": \".claude/rules/retired.md\"\n },\n {\n \"action\": \"delete\",\n \"kind\": \"directory\",\n \"feature\": \"skills\",\n \"path\": \".claude/skills/retired\"\n }\n ]\n }\n }\n}\n```\n\n- `action` is `write` or `delete`; `kind` is `file`, `directory` or `key`. A deleted directory is listed once, by its own path, together with everything under it. File and directory deletions come from the `--delete` orphan sweep, so without `--delete` the plan holds no `file` or `directory` deletion. A `key` deletion is part of a shared file's write and is reported with or without `--delete`.\n- `path` is relative to the output root, with `/` separators, and the operation does not name the root. A path several targets write is listed once, and so is a relative path that stands for a file in each of several output roots (`--output-roots a b`, or a tool home override such as `HERMES_HOME`): run one output root at a time when a consumer has to tell them apart.\n- The order is deterministic — features in summary order; within a feature, file writes, then key operations, then deletions; paths sorted, and keys within a path — so a `--dry-run` plan can be compared operation by operation with the plan the following real run reports.\n- A failing command's document carries no `data`, so when `--check` finds the tree out of date, or a `.rulesync/` source could not be read, the plan is reported as `error.details.plan` instead.\n- A shared configuration file Rulesync merges into (`.claude/settings.json`, `.codex/config.toml`, …) appears as a `write` of the whole file, followed by one `key` operation for each top-level key Rulesync owns outright there that the write adds or changes (`write`) or removes (`delete`), with the key's name in `key`. Removing the last MCP server from `.rulesync/mcp.jsonc`, for example, is a `delete` of `mcp_servers` in `.codex/config.toml` (and likewise for `.grok/config.toml`, `context_servers` in Zed's `settings.json`, `mcpServers` in `.augment/settings.json`, `.tabnine/agent/settings.json` and `~/.qoder/settings.json`, `mcp` in `crush.json`, `mcp_servers` in Pool's `settings.yaml`, and `amp.mcpServers` in Amp's `settings.json`; other targets still write an empty object there): the other keys of the file are kept as they are, and the file itself is never deleted. A key Rulesync shares with the user at a deeper level — the merged `permissions` block of `.claude/settings.json`, for example — is not listed, so its change shows only as the file `write`. A key two features write in the same run (the Hermes Agent `plugins` list) is listed under each feature that changes it, which a `--dry-run`, where neither sees the other's write, can report differently from the real run. Two rewrites the `--delete` sweep itself makes are not listed at all: disabling the Hermes Agent commands plugin in its `config.yaml` and retracting Goose slash commands — so `--check` can fail with an empty `operations` list when one of them is the only difference. `version` is bumped whenever an operation gains a new `action` or `kind` value or an existing field changes meaning, so a consumer that authorizes operations should refuse a version it does not know. Version 2 added the `key` kind.\n\n### Watch mode\n\n`generate --watch` runs one generation immediately and then keeps running, regenerating whenever the rulesync sources change. It is meant for iterating on rules, commands, subagents or skills without re-running the command by hand.\n\n- **What is watched**: within each input root (`.rulesync/` by default, or every `inputRoots` entry), only the top-level entries `generate` reads: the `rules/`, `commands/`, `subagents/`, `skills/` and `checks/` directories (recursively), the `mcp`, `hooks` and `permissions` files in both their `.jsonc` and legacy `.json` spellings (plus the deprecated `.mcp.json`), and `.aiignore`. Everything else under the root is pruned, so churn in unrelated siblings such as `.git/` or `node_modules/` never triggers a regeneration even when the root is the project directory itself. Symbolic links inside a root are followed, so a linked `rules/` directory reports edits to its targets. The configuration files next to the source tree (`rulesync.jsonc` and `rulesync.local.jsonc`, or the file passed to `--config`) are watched as well. Generated output is never watched, so a regeneration cannot re-trigger the watcher.\n- **Debouncing**: bursts of file-system events (editor save storms, `git checkout` switching many files) are coalesced into a single regeneration after a short quiet period. Changes that arrive while a generation is running trigger exactly one follow-up run.\n- **Errors keep the watcher alive**: a failing generation (e.g. invalid frontmatter saved mid-edit) is reported and watching continues; the process does not exit.\n- **Configuration changes**: editing the configuration file triggers a regeneration, and the new values apply to it because the configuration is re-resolved on every run. The **set of watched paths is fixed at startup**, so changing `inputRoot`/`inputRoots` (or the location of the configuration file itself) requires restarting the command. A warning is printed whenever the configuration file changes as a reminder.\n- **Incompatible flags**: `--watch` cannot be combined with `--check`, `--dry-run` or `--json`. The first two are one-shot verification modes and `--json` emits a single result document when the command exits, which never happens while watching.\n- **Stopping**: `Ctrl+C` (`SIGINT`) or `SIGTERM` closes the watchers and exits normally.\n\n### Tool home overrides win over the output root in global scope\n\nThree tools read their profile location from an environment variable: Hermes Agent (`HERMES_HOME`), Kimi Code (`KIMI_CODE_HOME`), and deepagents-cli (`DEEPAGENTS_HOME`). When one of them is set, `generate --global` and `convert --global` write that tool's output under it, **overriding both `outputRoots` and an explicit `--output-roots`** for that target. See [Supported Tools](./supported-tools.md) for what each profile root contains. This is deliberate — the variable names where the tool itself looks, so honoring the flag instead would produce files the tool never reads. Every other target still uses the configured output root.\n\nThe override must be a usable directory: an empty value is ignored (the default profile location applies), and a value that is the filesystem root or an unnormalized path is rejected with an error naming the variable. `DEEPAGENTS_HOME` is additionally held to the spellings dcode itself accepts — an absolute path, or one beginning with `~/` (expanded against the home directory) — so a relative value, a bare `~`, or a `~user` form is rejected rather than resolved against the working directory, since dcode would refuse to start with it. The home directory itself (`DEEPAGENTS_HOME=~/`) is rejected for the same reason: a profile root owns everything beneath it, so that spelling would scatter the profile across your home.\n\n### Shared config files are never created empty\n\nSome outputs are files Rulesync merges into rather than owns, because the tool (or you) keeps unrelated settings there: `.amp/settings.json(c)`, `.antigravity/settings.json`, `.claude/settings.json`, `.claude/settings.local.json`, `.codex/config.toml`, `.copilot/settings.json`, `.devin/config.json`, `.factory/settings.json`, `.github/copilot/settings.json`, `.grok/config.toml`, `.grok/sandbox.toml`, `.vibe/config.toml`, `.vscode/settings.json`, `.zcode/config.json`, `.zcode/cli/config.json`, `.zed/settings.json`, `kilo.json(c)`, `.mimocode/mimocode.json(c)`, `opencode.json(c)`, and `reasonix.toml`. These are deliberately **not** added to `.gitignore` by `rulesync gitignore`, so that settings you hand-author in them stay version-controlled.\n\nBecause they stay committable, `generate` will not **create** one of them just to hold an empty payload: if Rulesync has nothing to contribute (e.g. no permissions map to that tool), the file is left absent instead of being written as `{}`. A file that already exists is always rewritten as usual, so nothing you authored is dropped. Every other generated file is written even when empty, since for a file Rulesync owns its existence is part of the output.\n\n### Comments in shared JSONC files are preserved\n\nSeveral of those shared files are JSONC rather than JSON, because the tools themselves put comments in them — VS Code's own \"MCP: Add Server\" scaffold opens `.vscode/mcp.json` with a comment line. `.vscode/settings.json`, `.vscode/mcp.json`, `.amp/settings.json`, `opencode.json(c)` and `kilo.json(c)` (with their global counterparts) are therefore written back as **edits to the existing text**: only the spans whose values actually changed are rewritten, so the comments, blank lines, and key order everywhere else in the file survive a regenerate, and a `generate` that computes the same content it wrote last time leaves the file byte-identical. A key that Rulesync drops takes its own line with it, but a comment on the line above it stays, because Rulesync cannot tell whether that note described the key or the file around it. A note written after a key on the same line belongs to that key: it is removed with the key, and it stays put when Rulesync adds a new key after it. New keys are appended at the end of the object that holds them, and the region the insert touches is re-indented to the file's own indentation — so an object written on a single line is expanded to one key per line when Rulesync adds to it.\n\nRulesync falls back to rewriting the whole document when there is nothing to preserve, nothing to edit against, or no way to edit safely: an empty file; a file that does not parse, a byte-order mark included (for `.vscode/settings.json`, `.vscode/mcp.json` and `.amp/settings.json` an unparsable file stops the command instead, rather than have Rulesync overwrite settings it could not read); a file whose root is not an object; a file using `__proto__`, `constructor` or `prototype` as a key (those keys are dropped from every document Rulesync parses, so they are removed rather than left behind); a file that states the same key twice, where an edit would land on the copy the tool itself ignores; a file so large, with so much of it changing, that editing it key by key would take longer than a rewrite is worth, whether the size is the file Rulesync starts from or the document it is taking (a machine-generated file with thousands of entries, all of them replaced, or a small file handed thousands of new ones); a file whose changed keys write more than about 200 KB of new text between them, indentation included, which costs more to re-indent than to write out; and a file the editor itself refuses, which it reports as an error rather than a result. Except for the empty file, every such whole-document rewrite is reported as a warning naming the file and the reason (once per file and reason per run), so a comment that goes missing is announced by the `generate` that dropped it rather than discovered in the next diff. Files in the other formats — JSON, YAML, TOML — are re-serialized as before, so comments in `.codex/config.toml` or `reasonix.toml` are still not retained.\n\n## Gitignore Command\n\nThe `gitignore` command adds generated AI tool configuration files to `.gitignore`. By default, it emits entries only for the tools listed in the `targets` of your `rulesync.jsonc` (controlled by the `gitignoreTargetsOnly` option, which defaults to `true`). Set `gitignoreTargetsOnly` to `false` to emit entries for all supported tools instead. You can also filter the output per-invocation with `--targets` / `--features`, which take precedence over the config.\n\nYou can route entries to `.gitattributes` instead by setting `gitignoreDestination` to `\"gitattributes\"` at root, tool, or tool × feature level. More specific settings take precedence.\n\n> **No `rulesync.jsonc` in the project?** Entries for all supported tools are emitted. `gitignoreTargetsOnly` is only applied when a config file exists, so users without a config still get useful `.gitignore` coverage.\n\n> **`agentsmd` entries are always included.** Even when `gitignoreTargetsOnly` is `true` and `agentsmd` is not listed in `targets`, entries for `AGENTS.md` (and related paths) are appended automatically. Because `AGENTS.md` is a de facto standard file read by many AI tools regardless of the target set, its gitignore entries are emitted unconditionally to prevent accidental commits of generated rule files. To opt out of this behavior, pass an explicit `--targets` option that omits `agentsmd`.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |\n| `--targets, -t <tools>` | Comma-separated list of tools to include (e.g., `claudecode,copilot` or `*` for all) | Derived from `targets` / `gitignoreTargetsOnly` |\n| `--features, -f <features>` | Comma-separated list of features to include (rules, commands, subagents, skills, ignore, mcp, hooks, checks) | `*` (all) |\n\n### Examples\n\n```bash\n# Add all entries (default)\nrulesync gitignore\n\n# Add entries for Claude Code only\nrulesync gitignore --targets claudecode\n\n# Add entries for multiple tools\nrulesync gitignore --targets claudecode,copilot,cursor\n\n# Add only rules and commands entries for Copilot\nrulesync gitignore --targets copilot --features rules,commands\n```\n\n### Behavior\n\n- **Common entries** (e.g., `.rulesync/rules/.curated/`, `.rulesync/skills/.curated/`, `rulesync.local.jsonc`) are always included regardless of filters.\n- **General entries** (e.g., memories, settings) are always included when their target is selected.\n- When re-running, all previously generated rulesync entries are removed before writing the new filtered set.\n\n## Add Command\n\nThe `add` command can scaffold one Rulesync feature file or append one declarative source to `rulesync.jsonc`.\n\n### Feature scaffolding\n\nUse a feature keyword to create a valid, editable starter file:\n\n```bash\n# Named Markdown features\nrulesync add rule --name overview\nrulesync add command --name review-pr.md\nrulesync add subagent --name planner\nrulesync add skill --name project-context\nrulesync add check --name security\n\n# Singleton features\nrulesync add mcp\nrulesync add hooks\nrulesync add permissions\n\n# Deprecated compatibility scaffold; prefer permissions\nrulesync add ignore\n```\n\nNamed features accept a name with or without the `.md` suffix. Skills use the directory layout `.rulesync/skills/<name>/SKILL.md`; the other named features create `<name>.md` in their canonical Rulesync directory. Names cannot contain path separators.\n\nWhen the target file exists, interactive execution asks before replacing it. Declining leaves the file unchanged. JSON, silent, and non-interactive execution fail safely; pass `--force` to overwrite explicitly. Singleton scaffolds recognize supported JSONC and legacy variants and replace the effective existing file instead of creating a shadowed canonical file.\n\nFeature keywords are reserved when no source-specific option is present. To add a source whose identifier is also a feature keyword, provide a source option that makes the intent explicit, such as `rulesync add skill --transport npm`.\n\n### Declarative sources\n\nFor any other source identifier, `add` appends one source to `rulesync.jsonc` and immediately runs the declarative source resolver. It preserves JSONC comments, installs selected rules into `.rulesync/rules/.curated/`, installs selected skills into `.rulesync/skills/.curated/`, and updates `rulesync.lock` or `rulesync-npm.lock.json`.\n\n```bash\n# GitHub source (default transport)\nrulesync add anthropics/skills --skills skill-creator\n\n# Rules only; direct .md files are selected from rules/\nrulesync add acme/ai-standards --rules testing-guidelines,typescript-conventions\n\n# Rules and skills from separate paths in one source\nrulesync add acme/ai-assets --rules \"*\" --rules-path exports/rules --skills review-pr --path exports/skills\n\n# Any Git remote through the git CLI\nrulesync add https://example.com/team/skills.git --transport git --ref main --path skills\n\n# npm-compatible registry\nrulesync add @acme/skill-package --transport npm --registry https://registry.npmjs.org\n```\n\nThe selected configuration file must already exist. Run `rulesync init` first, or pass `--config <path>`. Adding a source whose normalized source identity is already present fails instead of silently creating duplicate lockfile entries; edit the existing entry when changing its options.\n\nThe operation fetches only the source being added; existing declarations are not re-fetched. Existing sources must already be locked and installed, otherwise run `rulesync install` first. The operation is transactional: if the new source fails to install, Rulesync restores the manifest, source lockfiles, curated rules, and curated skills to their pre-command state.\n\n| Option | Description |\n| --------------------- | ------------------------------------------------------------------------------------------------- |\n| `--name <name>` | Name for a rule, command, subagent, skill, or check scaffold |\n| `--force` | Replace an existing scaffold file without prompting |\n| `--skills <skills>` | Comma-separated skill names. `*` selects all skills. |\n| `--rules <rules>` | Comma-separated rule names. Names may omit `.md`; `*` selects direct `.md` files under rulesPath. |\n| `--transport <type>` | `github` (default), `git`, or experimental `npm` |\n| `--ref <ref>` | Git ref, npm version, or npm dist-tag |\n| `--path <path>` | Skills path within the source; defaults to `skills` |\n| `--rules-path <path>` | Rules path within the source; defaults to `rules` |\n| `--registry <url>` | npm-compatible registry URL |\n| `--token-env <name>` | Environment variable containing the npm registry token |\n| `--token <token>` | GitHub token for private repositories |\n| `--config <path>` | Configuration file to edit (default: `rulesync.jsonc`) |\n\nWhen neither `--skills` nor `--rules` is provided, all skills are installed for backward compatibility. Providing only `--rules` installs no skills.\n\n## Fetch Command\n\nThe `fetch` command allows you to fetch configuration files directly from a Git repository (GitHub/GitLab).\n\n> [!NOTE]\n> This feature is in development and may change in future releases.\n\n**Note:** The fetch command searches for feature directories (`rules/`, `commands/`, `skills/`, `subagents/`, etc.) directly at the specified path, without requiring a `.rulesync/` directory structure. This allows fetching from external repositories like `vercel-labs/agent-skills` or `anthropics/skills`.\n\n### Source Formats\n\n```bash\n# Full URL format\nrulesync fetch https://github.com/owner/repo\nrulesync fetch https://github.com/owner/repo/tree/branch\nrulesync fetch https://github.com/owner/repo/tree/branch/path/to/subdir\nrulesync fetch https://gitlab.com/owner/repo # GitLab (planned)\n\n# Prefix format\nrulesync fetch github:owner/repo\nrulesync fetch gitlab:owner/repo # GitLab (planned)\n\n# Shorthand format (defaults to GitHub)\nrulesync fetch owner/repo\nrulesync fetch owner/repo@ref # Specify branch/tag/commit\nrulesync fetch owner/repo:path # Specify subdirectory\nrulesync fetch owner/repo@ref:path # Both ref and path\n```\n\n### Options\n\n| Option | Description | Default |\n| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |\n| `--target, -t <target>` | Target format to interpret files as (e.g., 'rulesync', 'claudecode') | `rulesync` |\n| `--features <features>` | Comma-separated features to fetch (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `skills` |\n| `--output <dir>` | Output directory relative to project root | `.rulesync` |\n| `--conflict <strategy>` | Conflict resolution: `overwrite` or `skip` | `overwrite` |\n| `--no-prune` | Keep local files inside a fetched skill directory that the remote skill no longer has | Pruning is on |\n| `--ref <ref>` | Git ref (branch/tag/commit) to fetch from | Default branch |\n| `--path <path>` | Subdirectory in the repository | `.` (root) |\n| `--skills <skills>` | Comma-separated skill names to fetch (requires the skills feature) | All skills |\n| `--interactive, -i` | Interactively select skills to fetch via a checkbox prompt; nothing is selected initially, press `<a>` to select/deselect all (requires the skills feature and a TTY) | Disabled |\n| `--token <token>` | Git provider token for private repositories | `GITHUB_TOKEN` or `GH_TOKEN` env |\n\n### Executable Bits\n\nSkills can ship scripts that agents are expected to run. The GitHub contents API that `fetch` downloads from carries no file mode, so when a run fetches a skill's supporting files `fetch` reads the repository tree once and marks every such file that git records as executable (`100755`) with mode `0755` after writing it (a rules-only fetch makes no tree request); `rulesync generate` then carries that bit into each generated skill directory. Two warnings cover the cases where this cannot be done: a tree listing GitHub cut short (very large repositories), and a tree that could not be read at all. In both cases the files are still fetched, without the bit, and the warning names the file modes as what was lost so you can `chmod` them yourself.\n\n### Pruning Fetched Skill Directories\n\nA skill is a directory (`skills/<name>/SKILL.md` plus its supporting files), not a single file. When the upstream skill drops or renames a file, an additive fetch would leave the old local copy in place, and the directory would become a mixture of the current upstream files and orphaned leftovers. Agents read whatever is in the directory, so a stale reference or an outdated script keeps steering them long after upstream removed it.\n\n**The remote skill is therefore the source of truth: by default, `fetch` deletes files inside the skill directories it fetched that the remote does not have.** Every deletion is listed in the summary, so the destructive part of the command is never silent:\n\n```text\nFetched from anthropics/skills@main:\n ✓ skills/pdf/SKILL.md (overwritten)\n ✗ skills/pdf/reference.md (deleted - no longer in the remote skill)\n\nSummary: 1 overwritten, 1 deleted\n```\n\nRulesync also warns separately whenever a run deleted anything, so the one part of the command that cannot be undone is not left to be spotted among the rest of the summary.\n\n> [!WARNING]\n> Pruning removes **any** file in a fetched skill directory that the remote does not have — including one you added yourself. That is what \"mirror the remote\" means. Keep your own material outside the skill directories you fetch, or pass `--no-prune`.\n>\n> This applies to whatever `--output` points at. `--output .` makes the fetched skill directories your project's own `skills/`, so a fetch there prunes the skills you maintain by hand. Fetch into the default `.rulesync/` unless you really mean to mirror a remote repository into your project root.\n\nThe scope is deliberately narrow:\n\n- Only the `skills/<name>/` directories fetched in **this** run are pruned. A skill left out by `--skills` or `--interactive` is untouched.\n- Other features (`rules/`, `commands/`, `subagents/`, …) are never pruned — the issue only exists for directory-based skills.\n- Directories the remote skill no longer has are removed as well, and are listed in the summary under their own name with a trailing slash.\n- Symbolic links are unlinked, never followed: a link inside a skill directory can only ever lose the link itself, never whatever it points at. A skill directory that is **itself** a symbolic link is not pruned at all, since deleting through it would reach outside the output directory; Rulesync warns and leaves it alone.\n- A local file that the filesystem holds as the same file as one just fetched — a second name for it on a case-insensitive or Unicode-normalizing filesystem, or a hard link — is kept, even though the remote list does not carry that name. The same applies to a symbolic link that resolves to a file or directory this run wrote: it is kept rather than unlinked, since the fetch may have written through it.\n- A skill directory Rulesync cannot read or delete from — a permission it does not hold, a disk that gave out — stops that skill's prune where it failed rather than the whole fetch. Rulesync warns, still lists whatever it had already deleted, and moves on to the next skill.\n- Nothing more than 15 directories below the skill directory is pruned. That is a limit on the local walk, deep enough that a fetched tree stays well inside it; Rulesync warns and leaves anything deeper alone.\n- A skill directory whose name ends in a dot or an ASCII space, whose name has the `NAME~1` shape of a Windows short name, or whose name is a Windows reserved device name such as `NUL` or `COM1`, is never fetched in the first place (see [Skill Names That Look Alike](#skill-names-that-look-alike)), so there is nothing of it to prune. A local directory of such a name is left exactly as it was. A file _inside_ a skill whose own path has such a segment is skipped on its own (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)), and the skill it belongs to is then not pruned in that run.\n- A skill directory whose name differs from another in `skills/` — one that was already there, or one this same fetch just wrote — only in ways some filesystems ignore — its case, or whether an accented letter is written composed or decomposed — is not pruned either, for the same reason: macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, and macOS resolves a decomposed name to the composed directory of the same name, so pruning it would judge the local skill's own files stale.\n- A skill whose remote listing came back incomplete — GitHub caps a directory listing at 1,000 entries, and entries such as symlinks and submodules cannot be fetched — is not pruned either. Rulesync warns instead, because a local file that upstream still ships cannot be told apart from one it dropped.\n- `--conflict skip` disables pruning. That flag says to leave existing local files alone, and it also means the local copies are not this run's output, so they cannot be judged against the remote list.\n- `--target <tool>` never prunes, because that conversion path does not fetch skills at all.\n\nPass `--no-prune` to get the old purely additive behavior.\n\n#### Remote Paths Windows Reads Differently\n\nA backslash is an ordinary character in a filename on Linux and macOS, and a directory separator on Windows. A colon is ordinary too here, and on Windows it separates a file from one of its alternate data streams, so `skills/pdf::$INDEX_ALLOCATION` is another way of writing `skills/pdf` rather than a directory of its own. A remote file whose path contains either character therefore names one file on some systems and something else on others, so `fetch` skips it and warns rather than picking an interpretation. The rest of the fetch continues normally.\n\nThe same applies, segment by segment, to every path `fetch` writes — a rules file, a directory inside a skill, the file at the end of the path — when a segment is one Windows resolves to a different name: a segment ending in a dot or an ASCII space, which Windows strips, so `skills/deploy/references/notes.` is the existing `notes` there; one shaped like a `NAME~1` 8.3 short name, such as `scripts~1/run.sh`, which opens whatever long name it stands for; or a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` and `con.sh` count, as do `nul .md` with a space before the extension and `com¹` with a superscript digit, both of which Windows reads as the device — which is not a file there at all but the device itself. Such a file would land beside, or on top of, a sibling the repository never published, so it is skipped with a warning that quotes the segment as it is (a trailing space would not show otherwise), on every platform and not only on Windows, because the same output directory may be checked out on several. A skill directory of such a name is handled one level up, by dropping the whole skill (see [Skill Names That Look Alike](#skill-names-that-look-alike)).\n\nBecause the skipped file is still part of the remote skill, the skill directory it came from is not pruned in that run either — a local copy of a file the remote still ships would otherwise be indistinguishable from one it dropped.\n\n#### Skill Names That Look Alike\n\nA repository can publish two skill directories whose names a terminal draws the same way — `skill` spelled with a Cyrillic `ѕ`, or the fullwidth `skill` beside the plain one. Each is still a separate entry with its own name, so a selection writes exactly the directories that were checked; the risk is only that an entry cannot be told apart by sight from what it appears to be — another entry, a skill you already have, or the plainer, shorter name its own row reads as.\n\nThe interactive prompt therefore prefixes such an entry with `[!]` and the reason, ahead of the name itself:\n\n```text\n? Select skills to fetch (press <a> to select/deselect all)\n ◯ pdf\n ◯ [!] another entry differs from it only by lookalike letters — skill\n ◯ [!] another entry differs from it only by lookalike letters; mi… — ѕkill\n```\n\nThe mark comes first so that a name — which the remote repository chooses — cannot be spelled to look like a mark of its own, or reorder one away. A label wider than one line is shortened with an ellipsis for the same reason; the budget is measured in terminal columns, so a name of ideographic spaces cannot buy extra width by being few characters, and it is taken from the terminal the prompt is drawn in — the window's own width, less the five columns the pointer and the checkbox can take between them, and never more than 72 — so a pane split in half shortens the labels along with it. A terminal that does not say how wide it is counts as whatever the `CLI_WIDTH` environment variable says, and as 80 columns when that is unset too, the same fallback the prompt uses when it wraps the rows, and no terminal shortens a label below 16: past that a row would be an ellipsis and little else, and rows that cannot be told apart at all are worse than a row that wraps. The name is measured first and the reasons take the room that is left, which is why the second label above is cut: a cut takes the reasons from the tail, and the mark and the start of the first reason always survive. Two entries whose labels read alike are numbered — `(1) `, `(2) `, in front for the same reason the mark is — so they stay distinct. That covers labels shortened into the same text, and equally `git` beside `ɡit`, where both rows carry the same note and the names are one shape: the numbers do not say which row is which, but they do say there are two of them, and the value behind a label is untouched, so a shortened entry still selects the skill it names. A name that itself begins the way a marked row does is given a mark of its own saying so — judged by the shape it is drawn in rather than the characters it is spelled with, so `(l)` and a `[ǃ]` written with U+01C3 LATIN LETTER RETROFLEX CLICK are marked alongside the plain `(1)` and `[!]`.\n\nAn entry is marked for any of seven reasons:\n\n- **The name carries more whitespace than the row shows.** This reason is written first: the picker cuts an overlong note from its tail, and every other reason says the row may be taken for another one, which can still be checked by eye, while this one says the row itself reaches further than it shows. A run of whitespace is drawn as one gap however long it is, and whitespace at either end of a name is drawn as nothing at all, so ` pdf` and `pdf reader` reach past what can be seen of them. Both halves of a pair like `pdf` and ` pdf` are already marked as sharing a display form; this reason is what marks the padded name when it is alone on the list. A single blank inside a name that is merely not the plain space is not marked here: it is drawn, so it is a question of which character was chosen rather than of how far the name reaches, and the reason below answers that one. At the start it is marked like any other blank, since there the question is not which character was chosen but that the name reaches past where it appears to end. At the end the same holds for every blank but the ASCII space: a name ending in that, or in a dot, is a name Windows folds onto another, and is dropped rather than marked (see below).\n- **The name carries a mark drawn over the character before it.** An enclosing mark — the box of a keycap, U+20E3, or an enclosing circle, U+20DD — is drawn over the character in front of it and takes no column of its own, so `pdf` with one after it occupies the three columns of `pdf` and is a fourth directory underneath. It is drawn, so the name is offered rather than dropped, and marked whether or not a plain `pdf` is on the list. The one place such a mark belongs is an emoji keycap — `1️⃣`, spelled as UTS #51 spells it — which is not marked; every other enclosing mark is. Only enclosing marks count: the combining accents and vowel signs Vietnamese, Devanagari and Arabic are written with are ordinary and are left alone. Listed second, after the whitespace reason, for the same reason that one is first.\n- **The name carries a character drawn as a plain space.** A blank that a terminal draws in one column, exactly as it draws the plain space, without being one: U+00A0 NO-BREAK SPACE, U+1680 OGHAM SPACE MARK, the U+2000–U+200A quads and spaces, U+202F NARROW NO-BREAK SPACE and U+205F MEDIUM MATHEMATICAL SPACE. `pdf` and `reader` joined by one of those reads on screen as exactly `pdf reader`, and because names are compared with their whitespace collapsed and normalized, the pair is already reported when the plain spelling is on the list — this reason is what marks the substitution when it is alone there, the same case the lookalike tables cover for U+2010 HYPHEN. U+3000 IDEOGRAPHIC SPACE is not marked: it is two columns wide, so the row it is printed on tells it apart, and an ordinary `設定 ガイド` written with it is left alone. Listed third, after the mark reason, for the same reason those two come first: it says what the row itself is drawn as, whatever else is on the list.\n- **Another entry has the same display form.** Names are compared with their hidden characters removed, normalized (NFKC), their whitespace collapsed, and lowercased, so `Skill`, `skill`, ` skill` and the fullwidth `skill` all collide.\n- **Another entry differs from it only by lookalike letters.** Two names that read the same once each character is replaced by the Latin letter it is drawn as — `copy` beside the same word spelled entirely in Cyrillic. A name does not have to leave the Latin alphabet to qualify: `c0py` with a zero, `ruIes` with a capital I for the l, `Ⅰist` with the Roman numeral one, and `git` with the script `ɡ` or the dotless `ı` are all marked against the plain spelling. Two letters drawn as one count as well — `forrnat` for `format`, `revievv` for `review` — which no table of single characters can see. `cl` for `d` is left out of that short list on purpose: it opens too many ordinary words, and folding it would report `clone` against a `done` you happen to have. The separator counts too, since nearly every skill name is kebab-case and a name that swaps only its hyphen for U+2010 HYPHEN — drawn identically, belonging to no script, and left alone by the compatibility normalization — would otherwise pass every check as plain ASCII. Neither name mixes scripts on its own, so this pair is visible only by comparing the two.\n- **The name reads as Latin letters but is written in another script.** Every letter of it is drawn as a Latin one without being Latin, which is the whole-script confusable of UTS #39: `copy` spelled with four Cyrillic letters is marked even when no Latin `copy` is on the list. This check uses the narrower list of letters that are drawn as a _lowercase_ Latin letter while being lowercase themselves, so an ordinary Russian or Greek word — `текст`, `κατα` — is not marked: its letters are ones whose capitals resemble Latin capitals, which is not the same thing. Cherokee, Coptic, Lisu, Osage, Deseret and Tifinagh are taken whole instead of letter by letter — a name written in nothing but one of them is marked — since those alphabets are drawn in Latin letter shapes throughout. Canadian Aboriginal Syllabics and Vai are not: their letters are shapes of their own, so a name in either reads as nothing Latin, and only a mixture with Latin is marked.\n- **The name mixes scripts.** A single name built from scripts that share letter shapes, such as `good` with a Cyrillic `о`. The alphabets treated as lookalikes are Latin, Cyrillic, Greek, Armenian, Cherokee, Coptic, Lisu, Canadian Aboriginal Syllabics, Osage, Deseret, Vai and Tifinagh, which are among the ones UTS #39 records as confusable with each other. Japanese, Korean and Chinese names mix scripts by nature and routinely carry Latin, so those combinations are not marked, and neither is Latin beside a script that shares no shapes with it.\n\nThe display-form and lookalike reasons are asked of the skills already in the output directory as well as of the other entries, and say `a local skill` in place of `another entry` when that is where the twin is. Without that comparison a repository publishing only the imitation — `dep1oy` against a `deploy` you have had for months — is a single plain-ASCII name in one script with nothing on the list to compare it against, and every check stays quiet. A local skill that names the directory an entry would be written into is the skill that entry would refresh rather than one imitating it, so it is not a collision and is not marked; a second fetch of the same repository is therefore as quiet as the first. Where the two spellings differ only in case, or only in how the name is composed in Unicode, whether they name one directory is the filesystem's answer rather than the listing's — macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, Linux does not — so Rulesync asks it, and marks the pair only where they really are two directories — and only where the two also read alike, so that a local skill a _second_ entry imitates keeps its place in the comparison while the first entry refreshes it. A fullwidth `pdf` is a second directory everywhere and is always marked. Where both another entry and a local skill collide with a name, the entry is the one named, since that is the pair you can compare on screen.\n\nA `fetch` that shows no prompt — a plain one, or one selecting with `--skills` — prints the same reasons as a warning listing the names they apply to, so a scripted run is told what an interactive one would have been shown. The names are judged against everything the repository publishes and everything already in the output directory, as the prompt judges them, and listed for what the run actually writes: `--skills c0py` is told that the name reads like another entry even though the `copy` that makes it so was never fetched.\n\nThe mark is display-only: it never removes a skill from the list. It is also not a complete answer — the table of lookalike letters holds the common pairs rather than every one, and a name written entirely in a script the table does not map is compared against nothing — so treat it as a hint to look closer, not as a guarantee that unmarked entries are distinct.\n\nNames that cannot be shown honestly at all are a separate case: a skill directory whose name carries a control character, one that draws as nothing — a zero-width space, a Hangul filler, a braille blank — or one that draws as nothing but blank space or combining marks with no letter to sit on is dropped rather than marked, with a warning naming it in stripped form. So is a name that merely begins with a combining mark: the mark is drawn over whatever the terminal printed before the name — the checkbox, the quote around a name in a warning — and the name reads as the letters after it, which is what the warning shows. That drop applies to every `fetch`, including a plain one with neither `--skills` nor `--interactive`, because such a directory on disk cannot be told from the plain name it imitates in any line Rulesync prints.\n\nA name that Windows resolves to a different directory is dropped the same way, with a warning that quotes it as it is: a skill directory whose name ends in a dot or an ASCII space — Windows strips both when it opens a path, so `deploy.` is the existing `deploy` there; any other trailing blank it keeps, so a name ending in a no-break or an ideographic space is fetched and only marked — whose name has the `NAME~1` shape of an 8.3 short name, which on a volume that generates short names opens whatever long name it stands for, or whose name is a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9` or `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` counts, and so do `nul .md` and `com¹` — which cannot be a directory there at all. Such a name reads as one directory and is written into another, so with the default `--conflict overwrite` it would replace the files of a local skill the repository never published. The drop applies on every platform, not only on Windows, because the same output directory may be checked out on several; a repository that ships `deploy.` and `deploy` side by side loses only the first. The shape only counts at the end of a name, and the reservation only on the whole name before its first dot (spaces before that dot dropped first), so `data~2parser`, `v1.0`, `console` and `com10` are ordinary names and are fetched. The segments below the skill directory are held to the same shapes file by file (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)).\n\nA zero-width joiner or a variation selector is dropped only where it hides something. It is kept beside the scripts that are written with one — the Arabic family, the Indic scripts, Mongolian — and beside the pictographs an emoji sequence is built from; anywhere else it can be nothing but padding, so `pdf` with a joiner in it is dropped, and so is `設定` with one between its two characters. Standing where its own script puts it — a Persian or Indic name written with a zero-width non-joiner, an emoji name built from a chain of joiners — it is left alone and the name is fetched normally. A joiner is held to both of its neighbors, since it exists to bind two characters: a name that merely ends in one is a second directory drawn exactly like the name beside it, and is dropped. A variation selector is held only to the character before it, which is the one whose form it selects, so an emoji name may end in one. The keycap sequences — `1️⃣`, `#️⃣`, `*️⃣`, spelled as UTS #51 spells them: one of `0`–`9`, `#` or `*`, then U+FE0F, then U+20E3 — are matched whole, since what they are built on is a digit or an ASCII sign rather than a pictograph. Only the whole sequence is kept: a digit followed by a variation selector with no enclosing keycap behind it is padding still, and is dropped still.\n\n#### Remote Paths in Rulesync's Output\n\nPath names come from the remote repository, so every one that Rulesync prints — the fetch summary, warnings, and debug lines — has its control characters stripped first. A crafted path cannot forge or erase the lines around it, which is what makes the record of what was written and deleted worth reading.\n\nThe `--json` output is the exception: it carries each path exactly as the repository spells it, because a machine consumer needs the real name to act on it. Anything that renders a value out of that JSON into a terminal has to strip it itself.\n\n### Examples\n\n```bash\n# Fetch skills from external repositories\nrulesync fetch vercel-labs/agent-skills\nrulesync fetch anthropics/skills\n\n# Fetch only specific skills by name\nrulesync fetch anthropics/skills --skills pdf,docx\n\n# Interactively select which skills to fetch (checkbox prompt)\n# Nothing is checked when the prompt opens: press <space> to select the\n# highlighted skill, <a> to select/deselect all, <i> to invert, and <enter> to confirm.\nrulesync fetch anthropics/skills --interactive\n\n# Interactively select skills with some pre-checked\nrulesync fetch anthropics/skills --interactive --skills pdf\n\n# Fetch all features from a public repository\nrulesync fetch dyoshikawa/rulesync --path .rulesync --features \"*\"\n\n# Fetch only rules and commands from a specific tag\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\n\n# Fetch from a private repository (uses GITHUB_TOKEN env var)\nexport GITHUB_TOKEN=ghp_xxxx\nrulesync fetch owner/private-repo\n\n# Or use GitHub CLI to get the token\nGITHUB_TOKEN=$(gh auth token) rulesync fetch owner/private-repo\n\n# Preserve existing files (skip conflicts)\nrulesync fetch owner/repo --conflict skip\n\n# Keep local files a fetched skill no longer has upstream\nrulesync fetch anthropics/skills --no-prune\n\n# Fetch from a monorepo subdirectory\nrulesync fetch owner/repo:packages/my-package\n```\n\n## Convert Command\n\nThe `convert` command converts configuration files from one AI tool directly to one or more destination tools **without creating `.rulesync/` files on disk**. The intermediate rulesync representation is kept in memory only.\n\nThis is useful when you want to translate a one-shot tool-to-tool conversion (e.g., \"I have Cursor rules, give me Claude Code and Copilot equivalents\") without adopting rulesync's managed source-of-truth workflow.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------- |\n| `--from <tool>` | Source tool to convert from (single tool, e.g., `cursor`, `claudecode`) | Required |\n| `--to <tools>` | Comma-separated list of destination tools (e.g., `copilot,claudecode`) | Required |\n| `--features, -f <features>` | Comma-separated list of features to convert (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `*` (all) |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n| `--global, -g` | Convert for global (user scope) configuration files | `false` |\n| `--dry-run` | Show changes without writing files | `false` |\n\n### Examples\n\n```bash\n# Convert Cursor rules to Copilot and Claude Code\nrulesync convert --from cursor --to copilot,claudecode --features rules\n\n# Convert all features Cursor and Copilot both support\nrulesync convert --from cursor --to copilot\n\n# Convert MCP configuration from Claude Code to Cursor\nrulesync convert --from claudecode --to cursor --features mcp\n\n# Dry run to preview the conversion\nrulesync convert --from cursor --to copilot,claudecode --dry-run\n```\n\n### Behavior\n\n- The intermediate rulesync files produced during conversion are **never** written to disk. Only destination tool files are written.\n- Features that exist for the source tool but are not supported by a given destination tool are skipped with a warning.\n- When `--features` is omitted, the command attempts every feature the source tool supports.\n- Passing the source tool inside `--to` is rejected, because converting a tool onto itself is lossy.\n- With `--dry-run`, no destination files are written; the command prints a summary prefixed with `[DRY RUN]` listing what would have been converted.\n\n## Doctor Command\n\nThe `doctor` command runs read-only diagnostics against the configuration files (`rulesync.jsonc` and `rulesync.local.jsonc`) and reports problems grouped by severity (`error` / `warning` / `info`). It never writes files, which makes it a safe first step when generation does not behave as expected, and a cheap CI guard.\n\nIt is especially useful for catching **silently ignored configuration**: the config schema is non-strict, so a misspelled key such as `\"target\"` instead of `\"targets\"` is normally swallowed without any error. `doctor` reports every unknown key with a \"did you mean\" suggestion.\n\n### Checks\n\n- JSONC parse errors, reported with line and column.\n- Unknown or misspelled top-level keys, with a \"did you mean\" suggestion.\n- Unknown tool targets and features (array and object forms), with the nearest valid name suggested.\n- Deprecated features (`ignore`, superseded by `permissions`).\n- Object-form `targets` combined with `features` — including the case where the conflict only appears after merging `rulesync.jsonc` with `rulesync.local.jsonc`.\n- Conflicting target pairs (e.g. `claudecode` + `claudecode-legacy`).\n- `$schema` presence and whether it points at the current config schema URL.\n- Structural schema violations on any other key (wrong types, malformed `sources` entries).\n- `sources[].tokenEnv` naming an environment variable that is not set.\n- `inputRoot` or the first `inputRoots` entry pointing at a directory that does not exist. Later `inputRoots` entries are optional overlays and may be absent.\n- `inputRoot` or an `inputRoots` entry set to an empty string, which makes `generate` fail with `outputRoot cannot be an empty string` before it resolves any source tree.\n- Duplicate entries in `inputRoots` (warning; duplicates are ignored at generate time).\n\n### Options\n\n| Option | Description | Default |\n| --------------------- | --------------------------------- | ---------------- |\n| `--config, -c <path>` | Path to configuration file | `rulesync.jsonc` |\n| `--strict` | Treat warnings as errors (exit 1) | `false` |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n\n### Examples\n\n```bash\n# Diagnose the project configuration\nrulesync doctor\n\n# Fail CI on warnings too\nrulesync doctor --strict\n\n# Machine-readable output for editors and CI\nrulesync --json doctor\n\n# Diagnose a configuration file at a custom location\nrulesync doctor --config ./configs/rulesync.jsonc\n```\n\n### Behavior\n\n- Exits with code `1` when any `error`-severity diagnostic is present (or any `warning` with `--strict`), and `0` otherwise.\n- With the global `--json` flag, diagnostics and a severity summary are emitted as structured JSON: in `data` on success (exit 0), and in `error.details` of the standard error document (code `DOCTOR_FAILED`) on failure.\n- A missing configuration file is reported as `info` only — rulesync runs fine with built-in defaults.\n\n## Docs Command\n\nThe `docs` command prints the bundled Rulesync documentation to standard output, so both humans and coding agents can retrieve it directly in the terminal without browsing the repository or website. The documentation is embedded in the CLI at build time, so it works in installed npm distributions and compiled binaries alike.\n\nDocument identifiers follow the `docs/` hierarchy without the `docs/` prefix or the `.md` extension (both are accepted and stripped when supplied). Identifiers that try to escape the bundled tree — absolute paths, drive letters, `..` segments — are rejected.\n\n### Usage\n\n```bash\n# List every available document identifier\nrulesync docs\n\n# Print a document (top-level or nested)\nrulesync docs faq\nrulesync docs guide/configuration\n\n# Ranked full-text search across the bundled documentation\nrulesync docs --search \"global mode\"\n```\n\n### Search\n\n`--search <text>` builds an in-memory BM25+ index (via MiniSearch) over document paths, titles, headings, and body content, with stronger boosts for titles and headings. Up to 10 results are printed, one per line, as `<document> — <matching context>`. Matching is exact-term; no prefix or fuzzy expansion is applied.\n\n### Behavior\n\n- `rulesync docs` with no argument lists all document identifiers, one per line, sorted.\n- A missing document, an invalid identifier, an empty search text, a search with no matches, or combining a document argument with `--search` each exit with code 1 and an explanatory error.\n- Document output is printed verbatim, so it can be piped to other tools.\n- The global `--json` flag is not supported (the command's output is raw Markdown, not a JSON document) and exits with code 1.\n\n## Release Notes Command\n\nThe `release-notes` command prints GitHub release notes for any repository, so you can review what changed in an upstream AI coding tool — or in Rulesync itself — without leaving the terminal. Releases are fetched through the GitHub Releases API and rendered as Markdown on standard output, newest first.\n\nThe repository is given as `owner/repo` or a full `https://github.com/owner/repo` URL. Unlike `fetch`, ref (`@`) and path (`:`) suffixes are rejected — use `--tag` to select a single release. Only GitHub is supported; other Git providers have no equivalent Releases API.\n\n### Usage\n\n```bash\n# Latest 10 releases (default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Most recent N releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Releases published within a date range (either end may be omitted)\nrulesync release-notes dyoshikawa/rulesync --since 2026-01-01 --until 2026-06-30\n\n# A single release by tag name\nrulesync release-notes dyoshikawa/rulesync --tag v16.11.0\n\n# Every release between two tags, inclusive\nrulesync release-notes dyoshikawa/rulesync --from v16.0.0 --to v16.11.0\n\n# Include prereleases\nrulesync release-notes dyoshikawa/rulesync --include-prereleases\n\n# Machine-readable output\nrulesync --json release-notes dyoshikawa/rulesync --latest 3\n```\n\n### Filtering\n\nThe four filtering modes — `--latest`, `--since`/`--until`, `--tag`, and `--from`/`--to` — are mutually exclusive; combining them exits with code 1. With no filter, the latest 10 releases are printed.\n\n| Option | Description |\n| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--latest <count>` | Print the most recent `<count>` releases. Must be a positive integer. |\n| `--since <date>` / `--until <date>` | Print releases published within the range, both ends inclusive. Either end may be omitted for an open-ended range. Dates are parsed as ISO 8601, e.g. `2026-01-31`; a bare date given to `--until` covers that whole day in UTC. |\n| `--tag <tag>` | Print a single release by tag name. Named `--tag` rather than `--version` because `--version` is the global flag that prints the Rulesync version. |\n| `--from <tag>` / `--to <tag>` | Print every release between two tags, inclusive. Both are required. |\n| `--include-prereleases` | Include prereleases in the output. |\n| `--token <token>` | GitHub token for private repositories or higher rate limits. |\n\nTag ranges are resolved by position in the repository's release history, not by parsing semver, so non-semver tag names work and the order of `--from` and `--to` does not matter. A tag that does not appear in the history exits with code 1.\n\n### Authentication\n\nRequests are unauthenticated by default, which is enough for public repositories but subject to GitHub's stricter anonymous rate limit. Set `GITHUB_TOKEN` or `GH_TOKEN` (or pass `--token`) for private repositories and higher limits:\n\n```bash\nGITHUB_TOKEN=$(gh auth token) rulesync release-notes owner/private-repo\n```\n\n### Behavior\n\n- Draft releases are never printed: they are unpublished and only visible to accounts with write access.\n- Prereleases are excluded unless `--include-prereleases` is given, matching how GitHub itself resolves the \"latest\" release. `--tag` is the exception — an explicitly named tag is printed regardless of its prerelease status.\n- A repository with no matching releases prints a warning and exits with code `0`.\n- Range queries walk at most 10 API pages (1,000 releases); tags older than that are reported as not found.\n- Date ranges scan the whole walked history rather than stopping at the first out-of-range release, because the API orders releases by creation date and a release published from a long-lived branch can appear out of publication order.\n- Default output is Markdown on standard output, so it can be piped to other tools. With the global `--json` flag, the releases are emitted as structured `data` instead and no Markdown is printed; failures use the standard error document with code `RELEASE_NOTES_FAILED`.\n",
|
|
4324
4342
|
"reference/command-syntax": "# Command Syntax\n\nSlash commands authored under `.rulesync/commands/*.md` use a **universal syntax** that mirrors Claude Code's command placeholders. When rulesync generates a tool-specific command file, it rewrites these placeholders into the syntax that the target tool understands. The reverse rewrite happens on import, so a rulesync ↔ tool round-trip preserves the original universal form.\n\n## Universal placeholders\n\n| Placeholder | Meaning |\n| ------------ | ------------------------------------------------------------------------ |\n| `$ARGUMENTS` | The full argument string the user supplied when invoking the command. |\n| `` !`cmd` `` | Inline shell expansion. The agent runs `cmd` and substitutes its output. |\n\nThese are written exactly as Claude Code accepts them, so writing a rulesync command body is the same as writing a Claude Code command body.\n\n## Per-tool translation\n\nThe table below shows how each placeholder is translated for the supported tools. \"pass-through\" means the placeholder is emitted verbatim because the target tool already understands the universal form.\n\n| Tool | `$ARGUMENTS` | `` !`cmd` `` |\n| ----------------- | ---------------------- | --------------------------- |\n| Claude Code | pass-through | pass-through |\n| CodeBuddy Code | pass-through | pass-through |\n| Codex CLI[^codex] | pass-through (literal) | pass-through (literal) |\n| JetBrains Junie | `$prompt`[^junie] | pass-through (literal) |\n| Pi | pass-through | pass-through (literal)[^pi] |\n| Tabnine CLI | `{{args}}` | `!{cmd}` |\n| Other tools[^1] | pass-through (literal) | pass-through (literal) |\n\n[^1]: Tools not listed do not have a documented translation; their command body is emitted as-is.\n\n[^codex]: Codex CLI prompt files are forwarded to the LLM verbatim; the placeholders are passed to the model as literal text rather than being substituted by the engine.\n\n[^junie]: Junie treats every `$name` in a command template as a named argument and runs the command only once all of them are provided, so a literal `$ARGUMENTS` would become a required argument called `ARGUMENTS`. rulesync therefore rewrites it to Junie's free-form `$prompt` argument and adds `allowPromptArgument: true` to the generated frontmatter (an explicit `junie.allowPromptArgument` in the rulesync command takes precedence). The rewrite is skipped — `$ARGUMENTS` is emitted verbatim — when `junie.allowPromptArgument` is explicitly `false`, or when the body already contains any `$prompt` token, since merging the two would not survive a round-trip. On import, `$prompt` is rewritten back to `$ARGUMENTS` only when the command sets `allowPromptArgument: true` and the body holds no literal `$ARGUMENTS` — without that flag `$prompt` is an ordinary named argument and is kept as-is. Junie documents no shell expansion, so `` !`cmd` `` stays literal. See the [Junie custom slash commands docs](https://junie.jetbrains.com/docs/custom-slash-commands.html).\n\n[^pi]: Pi natively expands `$ARGUMENTS` (along with `$1`, `$2`, `$@`), so `$ARGUMENTS` is a real pass-through there. rulesync still emits `` !`cmd` `` verbatim for Pi, but does not assume Pi expands inline shell snippets — treat that placeholder as literal text on Pi's side.\n\nThe translation also runs in reverse when you import an existing tool command file via `rulesync import`, so a tool-native placeholder is rewritten back to the universal form in the generated `.rulesync/commands/*.md`.\n\n## Example\n\nGiven the following rulesync command:\n\n```md\n---\ntargets: [\"claudecode\"]\ndescription: \"Summarize git diff\"\n---\n\nSummarize the diff:\n!`git diff`\n\nFocus on $ARGUMENTS.\n```\n\nrulesync generates `.claude/commands/summarize.md`, passing the placeholders through verbatim because Claude Code already understands the universal form.\n\n## Notes\n\n- If you author a command with explicit tool-specific syntax (e.g. you write a tool-native placeholder directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools.\n- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\\n$ARGUMENTS\\n``` ` in a rulesync body will still be rewritten when generating tool output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\\$ARGUMENTS` is rewritten alongside the placeholder rather than producing a literal `$ARGUMENTS`.\n- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed.\n",
|
|
4325
|
-
"reference/file-formats": "# File Formats\n\n## Symlinks\n\nRulesync follows symbolic links when it discovers source files, whether you use a plain `.rulesync/` directory or separate `--input-roots`. Glob-based discovery (rules, commands, subagents, skills) follows symlinked files and directories; single fixed-path files such as `.rulesyncignore`, `.rulesync/mcp.jsonc`, and `.rulesync/permissions.jsonc` are likewise resolved transparently by the OS when read. A symlink inside the input tree that points elsewhere is followed transparently, and the resolved file content is copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks without duplication (see [issue #1707](https://github.com/dyoshikawa/rulesync/issues/1707)). A directory link that leads back to a directory the walk is already inside (for example `loop -> .` or `up -> ..` reaching the source tree again) is not descended a second time, so a link loop ends the walk instead of repeating it until the OS reports `ELOOP`; the files below that point are still found once, under the directory the walk is already inside.\n\nThe trust boundary is the directory you point Rulesync at. There is **no** `realpath`-based containment check on individual symlinks, so a link may resolve to a target outside the input root — enforcing containment would break the shared-file use case above. Only run Rulesync against trees you control. Directory symlink **cycles** are handled safely: a glob walk does not descend a link back into a directory it is already inside, and glob-based results are deduplicated by the real file they resolve to, so a cycle does not produce duplicated output, and two names for one file (a `docs/reference.md` link pointing at a top-level `reference.md`, say) yield a single entry. Skill directories are not discovered by globs at all — they are walked directly, and that walk keeps every entry it reaches, so a supporting file remains available under each name the skill gives it. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that never **follows** a symlink. A symlink in the remote repository is skipped rather than downloaded, so untrusted remote content never has its links resolved. Locally, the stale-file prune described in [CLI Commands](./cli-commands.md#pruning-fetched-skill-directories) removes a stale link itself without reading through it, and refuses to prune a skill directory that is a symlink at all, so a shared skill you linked in stays untouched.\n\nThree exceptions narrow this within skill directories. First, the entries a skill directory never carries (listed in the supporting-file note below) are excluded by the name they really resolve to, not just the name they have inside the skill — a link called `vendor` pointing at `~/.aws` is refused exactly as a directory called `.aws` is. Second, a link whose real path lands in a system pseudo-filesystem (`/proc`, `/sys`, `/dev`) is refused: `/proc/self/environ` looks like an ordinary file but reads back the environment of the running process. Third, a skill directory's companion files include hidden (dot-prefixed) entries, so an ordinary-looking link to a home directory would otherwise pull in every dotfile beneath it: a hidden entry whose real path resolves outside the skill directory is **not** carried, and the skipped paths are named in a warning. Copy such a file into the directory if the skill really needs it.\n\nWhat decides that third rule is the name inside the skill directory, not the path the link resolves through: a named file keeps the behavior above even when its target sits under a dot-directory such as `~/.dotfiles/skills/`, because somebody chose that name. Reaching outside the directory is reported either way — carried or not — since the content is about to be copied into every enabled tool root.\n\nOn the output side, generation does not write through a link that leads out of the output root. A checked-out repository can carry a symbolic link at an output path — an `AGENTS.md` pointing at `~/.bashrc`, or a tool directory pointing at `/etc` — and following it would land the generated content wherever the link points. Such a file or directory is skipped with a warning naming the path — a skill directory as a whole when any one file in it (its `SKILL.md` or a supporting file) is such a link — and a dangling link is judged by the target it would create, resolved link by link the way the OS would resolve it. A link that stays inside the output root is written through as before, so a dotfiles checkout linked from inside the home directory keeps working with `--global`. Under `--delete`, a skills or subagents directory that is itself such a link (for example `~/.cursor/skills` or `~/.claude/agents`) is written through but not swept: nothing under it is deleted, including stale files inside the skill directories this run writes, and the run warns that its orphan sweep was skipped and still succeeds, since sweeping through the link could delete files rulesync does not manage. Also under `--delete`, two tools whose output directories are one directory through such a link, such as `.cursor/commands` linked to `.claude/commands`, keep each other's files: a file only one of them generates is not swept as the other's orphan.\n\nOne discovery pass is deliberately excluded from the follow-symlinks rule: the scan for nested `AGENTS.md` files (see the `agentsmd` note below). Unlike every other glob above, it walks the whole project rather than a rulesync-owned directory, so a symlink committed to a repository you cloned could otherwise pull a file from outside the project into version-controlled `.rulesync/`. That scan does not follow symlinks.\n\n## `rulesync/rules/*.md`\n\nExample:\n\n```md\n---\nroot: true # true for root-level rules, false for details such as `.agents/memories/*.md`\nlocalRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI), Roo Code, Zoo Code and Devin: AGENTS.local.md; CodeBuddy Code: CODEBUDDY.local.md; Crush: CRUSH.local.md; Reasonix: REASONIX.local.md; Qwen Code: .qwen/QWEN.local.md; Others: append to root file. See the localRoot note below for import behavior\ntargets: [\"*\"] # * = all, or specific tools\ndescription: \"Rulesync project overview and development guidelines for unified AI rules management CLI tool\"\nglobs: [\"**/*\"] # file patterns to match (e.g., [\"*.md\", \"*.txt\"])\nagentsmd: # agentsmd and other nested-AGENTS.md readers (codexcli, warp, ...) specific parameters\n # Support for using nested AGENTS.md files for subprojects in a large monorepo.\n # This option is available only if root is false.\n # If subprojectPath is provided, the file is located in `${subprojectPath}/AGENTS.md`.\n # If subprojectPath is not provided and root is false, the file is located in `.agents/memories/*.md`.\n # Set it to \"auto\" to derive the directory from `globs` (`[\"packages/api/**/*\"]` -> `packages/api`)\n # for this rule alone; the `deriveSubprojectPathFromGlobs` config option does the same for every rule.\n # Set it to \"\" to keep this rule at its default placement even when that option is on.\n subprojectPath: \"path/to/subproject\"\ncursor: # cursor specific parameters\n alwaysApply: true\n description: \"Rulesync project overview and development guidelines for unified AI rules management CLI tool\"\n # Omit this key to fall back to the canonical `globs` above; set it to an\n # explicit `[]`, as here, to write the `.mdc` with no `globs` line at all.\n # `[]` is the right value beside `alwaysApply: true`: the flag already applies\n # the rule everywhere, and Cursor treats a glob next to it as a conflict. On a\n # rule that is *not* Always Apply, put the Cursor-specific patterns here\n # instead (e.g. `[\"src/**/*.ts\"]`). See the Cursor note below.\n globs: []\ncopilot: # copilot specific parameters (non-root `*.instructions.md` files only)\n name: \"TypeScript Style\" # (optional) display name shown in the VS Code UI; defaults to the file name\n excludeAgent: \"code-review\" # (optional) \"code-review\" or \"cloud-agent\": skip this file for that agent\n # Any other frontmatter key found in a hand-written `*.instructions.md` is imported into this\n # section and written back out, so a field Rulesync does not model is not lost on regeneration.\n # `description` and `applyTo` are the exception: they have canonical homes (`description` and\n # `globs`), so a value written for them in this section is overwritten by the canonical one.\nantigravity: # antigravity specific parameters\n trigger: \"always_on\" # always_on, glob, manual, or model_decision\n globs: [\"**/*\"] # (optional) file patterns to match when trigger is \"glob\"\n description: \"When to apply this rule\" # (optional) used with \"model_decision\" trigger\ndevin: # devin (Devin Desktop, formerly Windsurf) specific parameters\n trigger: \"always_on\" # always_on, glob, manual, or model_decision\n globs: [\"**/*\"] # (optional) file patterns to match when trigger is \"glob\"\n description: \"When to apply this rule\" # (optional) used with \"model_decision\" trigger\naiassistant: # aiassistant (JetBrains AI Assistant) specific parameters\n # (optional) Rule type written to the `apply:` line of `.aiassistant/rules/*.md`:\n # always, manually, by model decision, by file patterns, or off.\n # Omit it to derive the type from `globs` and `description`; see the AI Assistant note below.\n apply: \"always\"\ntrae: # trae specific parameters\n # (optional) Overrides the `alwaysApply` value derived from `globs` and `description`,\n # e.g. `false` on a rule with neither makes it apply only via `#Rule`. See the Trae note below.\n alwaysApply: false\n scene: \"git_message\" # (optional) Trae's `scene` field: also apply this rule to AI-generated Git commit messages\naugmentcode: # augmentcode specific parameters\n type: \"always_apply\" # always_apply, manual, or agent_requested\n description: \"When to apply this rule\" # (optional) used with \"agent_requested\" type\nkiro: # kiro specific parameters (steering inclusion)\n inclusion: \"fileMatch\" # always, fileMatch, manual, or auto\n fileMatchPattern: [\"src/components/**/*.tsx\"] # (optional) glob string or array of globs, used when inclusion is \"fileMatch\"\n name: \"api-design\" # (optional) required when inclusion is \"auto\"; the steering entry key\n description: \"REST API design patterns. Use when creating or modifying API endpoints.\" # (optional) required when inclusion is \"auto\"; Kiro auto-includes the file when a request matches this\ntakt: # takt specific parameters (optional; emitted under .takt/facets/policies/ — frontmatter is dropped on emit)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\n facet: \"output-contracts\" # (optional) \"policies\" (default) or \"output-contracts\": redirect this rule to Takt's output-structure/report-template facet\nfactorydroid: # factorydroid specific parameters\n # Route this non-root rule's body to one of Factory Droid's fixed files instead\n # of folding it into AGENTS.md / .factory/rules/*.md: \"design\" → the\n # design-guidelines DESIGN.md, \"threat-model\" → the Security Review attack-surface\n # map .factory/threat-model.md (both project scope only); \"output-style\" → a\n # custom output style of its own, .factory/output-styles/<rule file name>.md\n # (project and global scope). This option is available only if root is false.\n channel: \"design\"\n # (optional) With channel: \"output-style\", the style's picker name; Droid\n # defaults it to the file name without .md. Output-style rules should set\n # targets: [\"factorydroid\"] so other tools do not receive them as rules.\n name: \"Review Notes\"\n---\n\n# Rulesync Project Overview\n\nThis is Rulesync, a Node.js CLI tool that automatically generates configuration files for various AI development tools from unified AI rule files. The project enables teams to maintain consistent AI coding assistant rules across multiple tools.\n\n...\n```\n\nMultiple files can set `root: true` for the same target in project and global modes. Rulesync renders each file through the target adapter, then combines compatible root or plain-Markdown single-file outputs in deterministic source-discovery order with one blank line between fragments. Local rules are ordered lexicographically by source file path and composed before non-overridden `.curated/` rules, which are also ordered lexicographically; filename prefixes such as `10-` and `20-` control composition order within each set. Targets that map source rules to distinct native paths keep those files separate. Explicitly supported plain-Markdown modular rules that normalize to the same output path are combined; a fragment whose generated output carries its own frontmatter block (such as Amp's `globs:` gate) is never composed and stays a separate file instead. Unsafe collisions involving a source `root: true` rule fail. Other exact or case-insensitive modular collisions remain separate and produce a warning that the last write wins wherever the filesystem treats their paths as the same.\n\n> **localRoot import note:** For the tools that emit a separate personal local file (Claude Code and its legacy layout: `CLAUDE.local.md`; Rovodev, Roo Code, Zoo Code and Devin: `AGENTS.local.md`; CodeBuddy Code: `CODEBUDDY.local.md`; Crush: `CRUSH.local.md`; Reasonix: `REASONIX.local.md`; Qwen Code: `.qwen/QWEN.local.md`), `rulesync import` also reads that file back as a `localRoot: true` rule under `.rulesync/rules/`, keeping the tool-side basename. The imported rule's `targets` is scoped to the tool it was imported from, not `\"*\"` — a wildcard would spread the personal content into other tools' committed root files on the next generate (tools without a separate local file append `localRoot` bodies to their root file), and importing from several tools would otherwise produce conflicting wildcard `localRoot` rules. Widen `targets` by hand if you do want the content shared. The same scoping applies to `rulesync convert`: converting to a different tool drops the source tool's personal local file rather than folding it into the destination's root file. The derived `.gitignore` covers the imported copy via `.rulesync/rules/*.local.md`; run `rulesync gitignore` after a first import if the project's `.gitignore` has not been generated yet, so the personal content stays untracked. Project scope only, like `localRoot` generation itself.\n\n> **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. The path can also be **derived from the rule's `globs`**: set `agentsmd.subprojectPath: \"auto\"` on one rule, or `deriveSubprojectPathFromGlobs: true` in `rulesync.jsonc` for every non-root rule. The derived directory is the longest leading run of wildcard-free path segments that every glob shares, minus a trailing file name (`[\"packages/api/**/*.ts\"]` → `packages/api`, `[\"path/to/*\"]` → `path/to`), and it must be the same for all of the rule's globs. A wildcard-free final segment is read as a file name, not a directory (`[\"packages/api\"]` → `packages`), so write a directory as `packages/api/**`. Globs with no such prefix (`[\"**/*.ts\"]`, `[\"*.md\"]`, none at all), globs that disagree (`[\"packages/api/**\", \"packages/web/**\"]`), negations (`!…`), absolute paths, `..` segments, and brace expansion in the first segment (`{a,b}/**`) derive nothing: the rule keeps its default modular placement. Only a rule that asked with `\"auto\"` is warned about, once, naming the file; with `deriveSubprojectPathFromGlobs` the fallback is silent, since most rules carry activation globs such as `[\"src/**/*.ts\", \"test/**/*.ts\"]` that were never meant to name a directory. An explicit `agentsmd.subprojectPath` always wins over the derived value, an explicit `\"\"` opts a single rule out of the config option (default placement, nothing derived), and `root: true` rules never nest. Two rules that resolve to the same directory — derived, explicit, or one of each — collide exactly as two explicit `subprojectPath` rules do today (composed in source order for `agentsmd`; see each target's collision policy). Note that `globs` keeps its existing meaning for the targets that read it as a conditional-activation hint (Cursor, Cline, Kiro `fileMatch`, and others): derivation only adds where the file is placed, it does not change what the field means. Derived nested files are written through the same path as explicit ones, so `generate --delete` treats the two identically: it sweeps the `.agents/memories/*.md` file the rule used to produce (run it once after turning the option on), and it leaves the nested file on disk if the rule is later removed, exactly as described above. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/<directory-with-hyphens>.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See <https://agents.md/>.\n\n> **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. A rule carrying the shared directory-scoping carrier `agentsmd.subprojectPath` is written to `<dir>/AGENTS.md` **instead of** `.kiro/steering/`: Kiro CLI 2.18.0 and IDE 1.0.309 load `AGENTS.md` as steering context from anywhere in the workspace tree, so a directory-scoped rule reaches only the subtree it applies to rather than being flattened into an always-loaded steering file. A nested file is written plain (no `inclusion` block — that frontmatter belongs to `.kiro/steering/*.md`), and imports back as a `kiro`-targeted rule named after its directory (`services/api` → `services-api-kiro.md`). Like every other nested scan, discovery is **import-only**: the matches are hand-authored files outside a rulesync-owned directory, so `generate --delete` never sweeps them. Nesting is project scope only — under `~/.kiro/steering/` there is no workspace tree to scope against, so `subprojectPath` is ignored there. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`.\n\n> **Grok CLI note:** Grok Build writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.grok/AGENTS.md` (global, via `--global`), and non-root rules to `.grok/rules/*.md` (project) / `~/.grok/rules/*.md` (global). Grok scans that directory flat and in name order, alongside the AGENTS.md family — earlier Rulesync versions folded every topic rule into the single root file, which matched Grok 0.2.54 but not the current release, so regenerate to split them back out. Non-root files carry no frontmatter. Because this is a directory Grok defines rather than one Rulesync invented, a project may already have hand-written files there: Rulesync owns it from now on, so `--delete` removes anything in it — `~/.grok/rules/` included, in global mode — that `.rulesync/rules/` does not produce. Move those files into `.rulesync/rules/` first.\n\n> **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under `.kilo/rules/` — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.\n\n> In global mode (`--global`), Kilo's own layout is asymmetric: the root rule goes to `~/.config/kilo/AGENTS.md`, while non-root rules go to `~/.kilo/rules/*.md` — the same `.kilo`-relative path the skills adapter uses in both scopes. Global rules need no `instructions` registration, because Kilo auto-discovers every `~/.kilo/rules/*.md` on config load; writing the files is enough, and no global `kilo.jsonc` is touched by the rules feature.\n\n> **Kimi Code note:** Kimi Code reads `.kimi-code/AGENTS.md` at project scope and `~/.kimi-code/AGENTS.md` at user scope. When `KIMI_CODE_HOME` is set, Rulesync follows Kimi and resolves every global Kimi-specific file (`AGENTS.md`, `mcp.json`, `config.toml`, `skills/`, and `agents/`) under that custom data root; the shared `~/.agents/skills/` and `~/.agents/agents/` discovery roots remain under the user's real home directory. Because Kimi has no dedicated directory for topic-based instruction files, Rulesync folds every non-root rule body into that single file. See the [Kimi Code agents and instruction-files docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html) and [environment-variable docs](https://moonshotai.github.io/kimi-code/en/configuration/env-vars.html). The `kimi-code-plugin` target instead folds every rule into a plugin bundle's `SYSTEM.md` (project scope only; `localRoot` rules are not packaged); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **deepagents note:** deepagents-cli (dcode) reads `.deepagents/AGENTS.md` at project scope and `~/.deepagents/agent/AGENTS.md` at user scope. When `DEEPAGENTS_HOME` is set, Rulesync follows dcode and treats that directory as the profile root itself, so every global deepagents file moves under it with the `.deepagents` segment dropped: `$DEEPAGENTS_HOME/agent/AGENTS.md`, `$DEEPAGENTS_HOME/agent/skills/`, `$DEEPAGENTS_HOME/agent/agents/`, `$DEEPAGENTS_HOME/.mcp.json`, `$DEEPAGENTS_HOME/hooks.json`, and `$DEEPAGENTS_HOME/config.toml`. The `agent` segment is dcode's default agent profile; a different `[agents].default` in `config.toml` is not followed. See [Tool home overrides](./cli-commands.md#tool-home-overrides-win-over-the-output-root-in-global-scope) for how the variable is validated.\n\n> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). The same applies in **global** mode (via `--global`): OpenCode reads `instructions` from the global `~/.config/opencode/opencode.json` too, so global non-root rules are written to `~/.config/opencode/memories/*.md` and registered there (entries relative to the config file's directory, e.g. `memories/style.md`) instead of being dropped. This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under its managed rules directory (`.opencode/memories/`, or `memories/` in the global config) — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.\n\n> **MiMo Code note:** MiMo Code ([XiaomiMiMo/MiMo-Code](https://github.com/XiaomiMiMo/MiMo-Code)) is an OpenCode distribution, so the `mimocode` target reuses the OpenCode behavior above with MiMo's paths: the root rule goes to `AGENTS.md` (global: `~/.config/mimocode/AGENTS.md`), non-root rules to `.mimocode/memories/*.md` (global: `~/.config/mimocode/memories/*.md`), and they are registered in the `instructions` array of `.mimocode/mimocode.json`/`.mimocode/mimocode.jsonc` (global: `~/.config/mimocode/mimocode.json(c)`). MiMo Code resolves a relative `instructions` entry against the project directory rather than the config file's directory, so global entries are written home-rooted (e.g. `~/.config/mimocode/memories/style.md`), which MiMo Code expands.\n\n> **GitLab Duo CLI note:** GitLab Duo reads project [custom rules](https://docs.gitlab.com/user/duo_agent_platform/customize/custom_rules/) from `.gitlab/duo/chat-rules.md` and user rules from `~/.gitlab/duo/chat-rules.md` (global, via `--global`). The file is plain Markdown with no frontmatter and there is no rules directory, so the root rule and every non-root rule are folded into that one file. GitLab Duo also reads `AGENTS.md`, but that file belongs to the `agentsmd` target, so the `gitlabduo` target does not write it.\n\n> **Qwen Code note:** Qwen Code writes the root rule to the auto-loaded `QWEN.md` (project) / `~/.qwen/QWEN.md` (global, via `--global`) as plain Markdown, and non-root rules to its path-based context-rule directory `.qwen/rules/` (project) / `~/.qwen/rules/` (global). Each non-root rule is a Markdown file with optional YAML frontmatter: Rulesync maps `globs` ⇄ Qwen's `paths` (a picomatch glob array) and `description` ⇄ `description`. A rule **with** specific `paths` is _conditional_ — Qwen lazily injects it only when the model touches a matching file — while a rule **without** `paths` (empty or wildcard `**/*`/`*` globs) is a _baseline_ rule loaded at session start and is written as plain Markdown with no frontmatter block. The `.qwen/rules/` directory supersedes the legacy `.qwen/memories/` import surface, so each rule is emitted to exactly one location; the root `QWEN.md` is unchanged. A `localRoot: true` rule is emitted to `.qwen/QWEN.local.md` (project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the shared `QWEN.md` so it can override team instructions; the file is covered by the derived `.gitignore` since Qwen Code does not gitignore it for you. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code).\n\n> **Cline note:** Cline writes the root rule to the auto-loaded `AGENTS.md` (project) as plain Markdown, and non-root rules to its flat `.clinerules/` directory. Each non-root rule is a Markdown file with optional YAML frontmatter for conditional activation: Rulesync maps `globs` ⇄ Cline's `paths` (a glob array; the rule loads only when a matching file is in context) and `description` ⇄ `description`. A rule with **specific** `globs` emits `paths`; a rule with **universal** globs (`**/*` or `*`) emits `alwaysApply: true` (always load); a rule **without** globs is written as plain Markdown with no frontmatter block (always active). In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` (Cline CLI v3.0.15+) as plain Markdown, and non-root rules go to `~/Documents/Cline/Rules/*.md` — the global modular-rules directory both the VS Code extension and the SDK/CLI read — with the same conditional-frontmatter conversion project rules get. See the [Cline rules docs](https://docs.cline.bot/customization/cline-rules).\n\n> **Roo / Zoo Code note (rules):** At project scope both tools write the root rule to the auto-loaded workspace-root `AGENTS.md` and non-root rules to `.roo/rules/*.md`. In global mode (via `--global`) the root rule goes to **`~/.roo/rules/AGENTS.md`**, not `~/AGENTS.md`: the agent-rules loader discovers `AGENTS.md` / `AGENT.md` / `AGENTS.local.md` in the **workspace only** (the current directory, plus subdirectories holding a `.roo` folder when `enableSubfolderRules` is on), so a home-directory `AGENTS.md` is never read, while `~/.roo/rules/` is loaded before the workspace rules. The root rule therefore joins the non-root files in the directory the extension does load, and keeps the `AGENTS.md` basename so it still reads as the root overview — which is also what identifies it as the root rule on `import --global`. A `localRoot` rule stays project-only: `AGENTS.local.md` is a workspace file, so global generation skips it with a warning. See the [Roo Code custom instructions docs](https://roocodeinc.github.io/Roo-Code/features/custom-instructions).\n\n> **Response language note:** When `rulesync.jsonc` sets the root `language` key, the generated **root** rule file (the one built from the `root: true` rule — `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.cursor/rules/overview.mdc`, ...) ends with a thematic break followed by `You must always answer in <language>. On the other hand, reasoning (thinking) should be in English to improve token efficiency.` The block is body content, never frontmatter, so it works for every format; nested rules do not get it. Claude Code is the exception: it has a native `language` setting, so Rulesync writes `\"language\": \"<name>\"` into `.claude/settings.local.json` (project) or `~/.claude/settings.json` (global) through the shared settings gateway instead of touching `CLAUDE.md`. On import the block is recognized for every supported language and stripped from the rulesync rule, so the language lives in `rulesync.jsonc` alone. See [Response Language](../guide/configuration.md#response-language).\n\n> **Cursor note (rules):** Cursor's Always Apply template is `alwaysApply: true` with `globs` omitted, and the [Cursor rules docs](https://cursor.com/docs/rules) say globs are ignored once that flag is set. The two together are a semantic conflict Cursor's own staff advise against — some versions classify such a rule by its glob and treat it as Agent Requested instead of Always — so Rulesync keeps them apart on both sides of the round trip. **Import** keeps the canonical `globs: [\"**/*\"]`, so the tools that have no `alwaysApply` equivalent still apply the rule everywhere, but records `cursor.globs: []`. **Generate** drops a _universal_ glob (`**/*` or `*`) from an Always Apply rule wherever it came from, so the `.mdc` goes back out with no `globs` line. A **specific** glob is kept even alongside the flag: it says something the flag cannot, so it stays the author's to keep. An earlier Rulesync version invented `globs: **/*` on import, so an import-then-generate round trip rewrote hand-written Always Apply rules; just regenerate after upgrading and the stray glob comes back out, whether it sits in `cursor.globs` or in the canonical `globs`. Rules that are not Always Apply are untouched: an empty `globs` stays empty for both fields.\n\n> The two ways `cursor.globs` can be absent mean different things, here and everywhere else a tool-specific `globs` appears. **Omitting** the key means \"no Cursor-specific opinion\", so the canonical `globs` is used; an explicit **`[]`** means \"this rule has no Cursor globs\" and overrides the canonical value. That override is one-way and silent — a rule with canonical `globs: [\"docs/**\"]` and `cursor: { alwaysApply: true, globs: [] }` applies to the whole project in Cursor while the canonical file reads as if it were scoped to `docs/`. Deleting `cursor.globs` therefore does not by itself remove a glob — the canonical value takes over instead. On an Always Apply rule that is invisible when the canonical value is universal, since generate drops it either way, but a _specific_ canonical glob comes back out. Note also that `cursor.globs: []` on a rule that has no `alwaysApply` leaves `description` as the only frontmatter key, which makes the rule Agent Requested — Cursor reads the description and decides whether to pull the rule in. Only a rule with no description either ends up with empty frontmatter, which is Apply Manually: it applies solely when it is `@`-mentioned.\n\n> Cursor rules are **project scope only**. The [Cursor rules docs](https://cursor.com/docs/rules) list four kinds of rules — Project Rules in `.cursor/rules`, User Rules, Team Rules and `AGENTS.md` (written by the `agentsmd` target, not `cursor`) — and none of them is a file in the home directory: User Rules are entered in Cursor's **Customize → Rules** settings and Team Rules are managed from the Cursor dashboard, with no documented on-disk format or API that Rulesync could write. The [Cursor CLI docs](https://cursor.com/docs/cli/using) likewise document only `.cursor/rules` plus the project-root `AGENTS.md` and `CLAUDE.md`. There is therefore no `~/.cursor/rules` equivalent to generate, and `rulesync generate --global --targets cursor --features rules` skips the feature with a warning instead of writing a directory Cursor never reads. For rules that should apply in every project, paste them into User Rules by hand, or generate global rules for a tool that has a home-directory rules file (see the [supported tools matrix](./supported-tools.md)).\n\n> **Warp note (rules):** Warp reads project rules from the root `AGENTS.md` (or the back-compat `WARP.md`) and does not scan a modular rules directory, so non-root rule bodies are folded into the single root `./AGENTS.md`. Directory-scoped rules are the exception: Warp also reads `AGENTS.md` files placed in subdirectories \"for more targeted guidance\", applying the root file plus the current directory's one, with the subdirectory's rules taking precedence over the root's — so a non-root rule carrying `agentsmd.subprojectPath` (explicit, or derived from `globs` via `\"auto\"` / `deriveSubprojectPathFromGlobs`) is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. Note the narrower reach this buys: Warp reliably applies a nested file only while the current directory is that subdirectory (other subdirectories are included on a best-effort basis when you edit files there), whereas a folded rule is read from anywhere in the project — keep a rule unscoped if it must always apply. Because Warp loads those files itself, the root `AGENTS.md` carries no reference list pointing at them. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`) and land in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`, exactly as described in the Vibe Code note below — and, likewise, a nested file emitted from a rule scoped with `targets: [\"warp\"]` is still read by every other AGENTS.md reader working in that directory. In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` — Warp's third rule source alongside project and Warp Drive rules, also used from remote hosts in SSH sessions — with the same folding (a directory-scoped rule folds there too, since the global root has no workspace to nest under). Warp gives a legacy `WARP.md` priority over `AGENTS.md` in the same directory, so when a `WARP.md` exists next to the generated root or nested `AGENTS.md`, Warp never reads that `AGENTS.md`; rulesync warns on generate but neither writes nor deletes `WARP.md` — move its content into `.rulesync/rules/` and remove or rename it. On import, a project-root `WARP.md` is read as the root rule when the root `AGENTS.md` is absent, so a repository that has not moved to `AGENTS.md` yet imports its rules; when both exist, `AGENTS.md` is imported and a warning says that Warp reads `WARP.md` instead. A nested `WARP.md` is not imported. Other targets (e.g. Cline) own the same global path; as with the shared project-root `AGENTS.md`, each target regenerates the file per its own semantics. See the [Warp rules docs](https://docs.warp.dev/agents/capabilities/rules/) and [file locations](https://docs.warp.dev/terminal/settings/file-locations/).\n\n> **Crush note (rules):** Crush reads project context from the root `CRUSH.md` (it also opportunistically reads `crush.md`/`Crush.md`, their `.local` variants, and the cross-tool `AGENTS.md`/`CLAUDE.md`/`GEMINI.md`/`.cursorrules`/`.cursor/rules/`/`.github/copilot-instructions.md`, all owned by other targets) and does not scan a modular non-root instructions directory, so non-root rule bodies are folded into the single root `./CRUSH.md`. A `localRoot: true` rule is the exception: it is written to `./CRUSH.local.md`, one of Crush's default context paths, rather than folded into the committed root file — project scope only, since Crush's global context paths have no `.local` variant. Crush does not gitignore that file for you, so the derived `.gitignore` covers it. In global mode (via `--global`), the root rule is written to `~/.config/crush/CRUSH.md` with the same folding. See the [Crush config source](https://github.com/charmbracelet/crush/blob/main/internal/config/config.go) and [context-file loading](https://github.com/charmbracelet/crush/blob/main/internal/config/load.go).\n\n> **Pi note:** Pi writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.pi/agent/AGENTS.md` (global, via `--global`) as plain Markdown, and folds non-root rules into that single file (Pi has no modular rules directory). Pi additionally loads two system-prompt instruction files. `.pi/APPEND_SYSTEM.md` (project) / `~/.pi/agent/APPEND_SYSTEM.md` (global) **appends** to the default system prompt, and Rulesync emits it from any rule that opts in via a `pi.systemPrompt: append` frontmatter block — those rule bodies are routed to `APPEND_SYSTEM.md` instead of `AGENTS.md`, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file (note: if you hand-authored `.pi/APPEND_SYSTEM.md` before this feature existed, `generate --delete` for the `pi` target now treats it as a managed path and removes it unless a rule opts in — import it first to convert it into a canonical rule). The opt-in is ignored on the `root: true` rule, which always stays on `AGENTS.md` (routing the root away would leave the context file without a merge target). `.pi/SYSTEM.md` (project) / `~/.pi/agent/SYSTEM.md` (global) **replaces** the default system prompt entirely — which silently disables Pi's built-in tool instructions — so Rulesync deliberately never emits it and leaves it to be authored by hand. Example:\n>\n> ```yaml\n> ---\n> targets: [\"pi\"]\n> description: \"House style for the system prompt\"\n> pi:\n> systemPrompt: append # routes this rule's body to .pi/APPEND_SYSTEM.md / ~/.pi/agent/APPEND_SYSTEM.md\n> ---\n> ```\n>\n> Pi tries `AGENTS.override.md` before `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md` and `CLAUDE.MD` in every directory it scans (including the global `~/.pi/agent/` one), loading it **instead of** the others from that directory. Set `pi.contextFile: override` on the **`root: true`** rule to emit the root context file under that name — useful when another target owns the shared `AGENTS.md`, or when a `CLAUDE.md` sits next to it and Pi should deterministically prefer Rulesync's output. Because Pi folds every rule body into the root context file, one opted-in root rule decides for the whole Pi output: the flag is applied to every other Pi rule (root ones included), and setting it _only_ on a non-root rule is ignored with a warning — emitting both files would hide everything left in `AGENTS.md`. `AGENTS.override.md` is Pi-exclusive, so it is imported and deleted like the root file, and toggling the flag off cleans it up. The project-root `AGENTS.md` is never deleted on Pi's behalf, with or without the flag: `agentsmd`, `codexcli`, `warp` and others write that same path, so the `pi` target leaves a stale one behind rather than removing another target's output (the global `~/.pi/agent/AGENTS.md` is Pi-exclusive and is still cleaned up). Example:\n>\n> ```yaml\n> ---\n> root: true\n> targets: [\"pi\"]\n> pi:\n> contextFile: override # emits AGENTS.override.md instead of AGENTS.md\n> ---\n> ```\n>\n> See the [Pi usage docs](https://pi.dev/docs/latest/usage) and the [context-file discovery in the Pi source](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/resource-loader.ts).\n\n> **oh-my-pi note:** oh-my-pi (`omp`, [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi)) is a Pi fork with its own `.omp/` configuration tree. Rulesync writes the root rule as plain Markdown to `.omp/AGENTS.md` (project) / `~/.omp/agent/AGENTS.md` (global, via `--global`), the native context file, which oh-my-pi reads in place of a root `AGENTS.md` at the same depth, and each non-root rule to `.omp/rules/<name>.md` / `~/.omp/agent/rules/<name>.md` with `description`, `globs` and `alwaysApply` frontmatter. oh-my-pi drops a rule that is neither `alwaysApply: true` nor described, so a rule whose `globs` are empty or match everything (`**/*`) is written as `alwaysApply: true`, and a glob-scoped rule without a `description` gets one generated from its globs. oh-my-pi reads only the top level of its rules directory, so a nested rule (`.rulesync/rules/frontend/style.md`) is flattened to `.omp/rules/frontend-style.md`. Import reads the top-level `*.md` and `*.mdc` files oh-my-pi reads, and an imported `.mdc` rule becomes a `.md` rulesync rule; generation always writes `.md`, so delete the original `.mdc` file after importing it (oh-my-pi treats `foo.md` and `foo.mdc` as the same rule). Flattening does not round-trip: importing `.omp/rules/frontend-style.md` creates `.rulesync/rules/frontend-style.md` beside the nested source rule, so keep only one of them. Nested files under `.omp/rules/` are not imported (oh-my-pi does not read them), but `generate` with `--delete` still removes nested `.md` files there, which earlier Rulesync versions wrote. `.omp/RULES.md` (sticky rule) and `.omp/SYSTEM.md` (system prompt replacement) are never emitted. Global paths assume the default profile (`~/.omp/agent`); a named profile (`OMP_PROFILE`) or a `PI_CODING_AGENT_DIR` override is not followed. See the [oh-my-pi context-file docs](https://github.com/can1357/oh-my-pi/blob/main/docs/context-files.md).\n\n> **Factory Droid note (rules):** Beyond the root `AGENTS.md` (project) / `~/.factory/AGENTS.md` (global, via `--global`) and non-root rules in `.factory/rules/*.md`, Factory Droid also loads two further fixed files on its own, and Rulesync emits each from any non-root rule that opts in via the `factorydroid.channel` frontmatter key. Opted-in rule bodies are routed to the channel's file instead of `AGENTS.md`/`.factory/rules/*.md`, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file:\n>\n> - `channel: design` → `DESIGN.md` at the project root: \"Always-on design-system, UX, visual, and interaction guidance\", loaded independently of `AGENTS.md`'s coding guidelines.\n> - `channel: threat-model` → `.factory/threat-model.md`: the attack-surface map Factory's Security Review reads (\"if `.factory/threat-model.md` exists, Droid uses it as the attack-surface map\"). On import it lands in `.rulesync/rules/threat-model.md`.\n>\n> Both files are excluded from the TOON reference list Factory Droid's root file otherwise embeds for non-root rules, since Factory Droid already loads them itself and listing them there would double them up. The opt-in is ignored on the `root: true` rule and in global mode: Factory's docs describe root and nested `DESIGN.md` files like `AGENTS.md`, and the threat model only as a repository file, but document no personal/global home-directory equivalent for either. Example:\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Color palette and spacing scale\"\n> factorydroid:\n> channel: design # routes this rule's body to DESIGN.md\n> ---\n> ```\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Trust boundaries and sensitive data flows\"\n> factorydroid:\n> channel: threat-model # routes this rule's body to .factory/threat-model.md\n> ---\n> ```\n>\n> A third value, `channel: output-style`, writes the rule as one of Factory Droid's [custom output styles](https://docs.factory.ai/droid-cli/output-styles) — response-presentation instructions a user picks in `/settings` — instead of a concatenated file: each opted-in rule becomes its own `.factory/output-styles/<rule file name>.md` (`~/.factory/output-styles/` with `--global`, the one channel that also works in global mode, since Factory documents the user scope too). The rule's `description` becomes the style's `description` frontmatter (shown below its name in the picker), and the optional `factorydroid.name` its `name` (the picker name, which Droid otherwise takes from the file name; generate warns when `name` is set without `channel: output-style`, where it is ignored). The file is excluded from the TOON reference list like the other channels, and import brings each style back as `.rulesync/rules/<file name>.md` with the same frontmatter. Because Droid loads only direct `.md` children of `output-styles/` and reserves its built-in `Default` and `Concise` styles, generate fails for an output-style rule nested in a subdirectory of `.rulesync/rules/` or whose style name is `default` / `concise` (case-insensitive). Unlike `.factory/rules/`, the directory is not swept by `generate --delete`: it also holds styles written by hand (including personal ones in `~/.factory/output-styles/`) that Rulesync cannot tell apart from its own, so a style no rule produces anymore — after renaming or removing the rule — has to be deleted by hand (or the next import brings it back as a rule). For the same reason `rulesync gitignore` adds no entry for it, and generate overwrites a hand-written style of the same file name without a warning — with `--global` that includes a personal style in `~/.factory/output-styles/`, so pick a rule file name no style of your own uses. Import brings back every `.md` file in the directory, hand-written styles included, but skips any style reached through a symbolic link — a linked file or a linked `output-styles` directory, at either scope — so a link's target is never copied into `.rulesync/rules/`; a style whose file name matches a `.factory/rules/*.md` rule imports to the same `.rulesync/rules/` path, and only one of the two is kept (a warning names both). Give an output-style rule `targets: [\"factorydroid\"]`, as in the example below: with any other target it also reaches those tools as an ordinary rule, putting presentation instructions in their project rules, and generate warns about it. Selecting the style (the `outputStyle` setting) stays with the user. Example:\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Put findings before the summary\"\n> factorydroid:\n> channel: output-style # writes .factory/output-styles/<this file name>.md\n> name: \"Review Notes\"\n> ---\n> ```\n>\n> See the [Factory Droid AGENTS.md configuration docs](https://docs.factory.ai/cli/configuration/agents-md), the [Security Review docs](https://docs.factory.ai/software-factory/security-review) and the [output styles docs](https://docs.factory.ai/droid-cli/output-styles).\n\n> **Devin note:** The root rule is emitted to the project-root `AGENTS.md` — the file [Devin CLI / Devin Local actually reads](https://docs.devin.ai/cli/extensibility/rules) (its rules page does not list `.devin/rules/` among its sources) — as plain markdown, while non-root rules keep going to `.devin/rules/*.md`, the Devin Desktop Cascade directory whose `trigger` activation modes (`always_on`, `glob`, `manual`, `model_decision`) are driven by the `devin` frontmatter block. Global mode mirrors that layout: the root rule is a plain `~/.config/devin/AGENTS.md`, and non-root rules are emitted one file per rule into `~/.devin/rules/*.md` with the same `trigger`/`globs` frontmatter. Note the directory split — the per-rule global directory is the home `~/.devin/`, not the `~/.config/devin/` tree the global root and Devin's other global surfaces use; that is what the rules page documents (`~/.devin/rules/*.md`, `~/.devin/global_rules.md`). The `devin-plugin` target writes the root rule to a plugin bundle's `AGENTS.md` and other rules to its `rules/<name>.md` (project scope only; `localRoot` rules are not packaged). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Amp note:** Amp gates an @-mentioned guidance file on `globs:` YAML frontmatter — the file is loaded only after Amp has read a file matching one of the globs, and **without** the frontmatter it is always loaded. Rulesync therefore emits each non-root rule's `globs` as that frontmatter on the generated `.agents/memories/*.md` file (in addition to the advisory `applyTo` value in the root file's TOON table, which Amp does not enforce), and restores it into the canonical `globs` on import. Amp implicitly prefixes each glob with `**/` unless it starts with `./` or `../`, so canonical globs pass through verbatim. See [Globs in AGENTS.md](https://ampcode.com/news/globs-in-AGENTS.md).\n\n> **JetBrains AI Assistant note:** AI Assistant reads every `.aiassistant/rules/*.md` file (project scope only; there is no root file, so the root rule is written there as its own file too) and stores each rule's type — the \"Rule type\" selector in the IDE — in the file itself, as a leading metadata block: `---` / `apply: <type>` / `---`, where `<type>` is one of `always`, `manually`, `by model decision` (with an `instructions: <condition>` line), `by file patterns` (with a `patterns: <glob>, <glob>` line — comma-separated, not a YAML list) or `off`. The block is line-based (`<field>: <value>`, split on the first colon) rather than YAML, and the values are matched case-sensitively. A file with **no** `apply:` line is typed **Off** when the plugin collects rules for a chat, i.e. it is never attached automatically (the Settings table still shows it as \"Always\"), which is why Rulesync always writes one. The type is derived from the canonical frontmatter unless `aiassistant.apply` sets it explicitly: `globs` with no universal entry become `apply: by file patterns` with the globs as `patterns`; a `description` on a rule without any `globs` becomes `apply: by model decision` with the description (flattened to one line) as `instructions`; everything else — the root rule, any list containing a universal glob (`**/*`, `*`) and rules with neither — is `apply: always`. Because the plugin splits `patterns` on every comma, a brace alternation such as `src/**/*.{ts,tsx}` is expanded into one pattern per branch (`src/**/*.ts, src/**/*.tsx`) before it is written, and a glob that still contains a comma — or any value containing `---`, which the plugin reads as the end of the block — is written with a warning. An explicit `aiassistant.apply` is written verbatim even when it is not one of the five types (with a warning — the current plugin treats such a rule as Off), and an explicit `by file patterns` writes every glob, universal ones included, as `patterns`. On import the block is stripped from the body and mapped back: `by file patterns` → `globs` (split on the commas outside brace groups), `by model decision` → `description`, `always` → `globs: [\"**/*\"]`. `aiassistant.apply` is kept whenever the next generate would not derive the same type on its own — `manually` / `off` and unrecognized values, which have no canonical counterpart, and a `by file patterns` / `by model decision` block whose `patterns` / `instructions` line is missing or universal — so a regenerate reproduces the file. This format comes from the plugin itself (`AiRulesService` and its message bundle, verified on build 262.10968.75) — the [project rules docs](https://www.jetbrains.com/help/ai-assistant/configure-project-rules.html) describe the rule type only as an IDE setting.\n\n> **Trae note:** Trae (ByteDance's AI IDE, called \"TraeCode\" in its docs) reads every `.trae/rules/*.md` file (project scope only; there is no root rule file, so the root rule is written there as its own file too, the same layout as Cursor). Each file carries Cursor's frontmatter triple, which selects one of Trae's four application modes: `alwaysApply: true` (always), `alwaysApply: false` with `globs` (specific files), `alwaysApply: false` with `description` (apply intelligently), and `alwaysApply: false` alone (manual, via `#Rule` in chat). The mode is derived from the canonical frontmatter unless `trae.alwaysApply` sets it: `globs` with no universal entry give `alwaysApply: false` plus the globs, written unquoted and comma-separated as Trae's editor writes them (`globs: *.ts,src/**/*.tsx`), with brace alternations expanded first because Trae splits the value on every comma (`src/**/*.{ts,tsx}` is written as `src/**/*.ts,src/**/*.tsx`; a glob that still holds a comma, such as one past the expansion cap, is written as is with a warning); a `description` on a rule without any `globs` gives `alwaysApply: false` plus the description; everything else — the root rule, any list containing a universal glob (`**/*`, `*`) and rules with neither — is `alwaysApply: true`, written without `globs` (an explicit `trae.alwaysApply: true` keeps the specific globs, and `trae.alwaysApply: false` keeps every glob, universal ones included, so a file-scoped `**/*` rule does not turn into a manual one). On import `alwaysApply: true` without globs maps to `globs: [\"**/*\"]`, an unquoted `globs` value is read literally (quoted before YAML parsing, so `{src,lib}/**` or `!**/test/**` do not break the import; YAML null and boolean keywords and block scalars are left to YAML) and split on the commas outside brace groups (a YAML flow list is accepted too; a value that only starts with `[`, such as the character-class glob `[abc]*.ts`, is read as a glob), and `trae.alwaysApply` is kept only when the next generate would not derive the same value (a manual rule, or `alwaysApply: true` beside specific globs). Trae's `scene` field (`scene: git_message` also applies the rule when Trae generates a Git commit message, as in the `git-commit-message.md` its Source Control panel creates) round-trips through `trae.scene`. As for any rules directory, `generate --delete` removes `.trae/rules/*.md` files that no Rulesync rule produces, so import a hand-made rule such as `git-commit-message.md` first if you want to keep it. Global rules are not generated: Trae documents `~/.trae/user_rules` only as a path, without saying whether it is a file or a directory or what goes in it. Trae also reads a `.trae/rules/` folder in any project subdirectory and applies it when files in that directory are read or mentioned, so a non-root rule carrying `agentsmd.subprojectPath` (explicit or derived from `globs`) is written to `<subprojectPath>/.trae/rules/<name>.md` instead (project scope only), with the same frontmatter mapping and its `globs` left as written. On **import**, those folders are found by a project scan (hidden directories, `node_modules/`, `__pycache__/` and root build/vendoring directories are skipped, symbolic links are not followed, and git-ignored directories are skipped — tested down to `<dir>/.trae/` only, so the `**/.trae/rules/` entry `rulesync gitignore` writes does not disable the scan) and each file lands in `.rulesync/rules/` under its own name (a subfolder inside the nested rules directory is kept, as for the root one) with `targets: [\"trae\"]` and the `subprojectPath` carried, so the next generate puts it back without other tools picking it up; two files with the same name in different rules directories collide, which import reports. Like every other nested scan this is import-only: `generate --delete` never removes a nested `.trae/rules/` file, so delete one by hand after dropping its rule. MCP servers go to the project `.trae/mcp.json` as a plain `mcpServers` object (user-level servers live in the IDE settings UI; other top-level keys already in the file are kept). Trae expands only `${workspaceFolder}`, so any other `${...}` reference in `env` or `headers` (such as `${API_KEY}`) reaches Trae as literal text and Rulesync warns about it. Skills go to `.trae/skills/<name>/SKILL.md` (project) / `~/.trae/skills/<name>/SKILL.md` (global) in the Agent Skills format. See the [Trae rules](https://docs.trae.ai/ide/rules?_lang=en), [skills](https://docs.trae.ai/ide/skills?_lang=en) and [MCP](https://docs.trae.ai/ide/add-mcp-servers?_lang=en) docs.\n\n> **Antigravity note (rules):** The Antigravity IDE and CLI share their project rules: the root rule is the project-root `AGENTS.md`, and every non-root rule is a file in `.agents/rules/` with Antigravity `trigger` frontmatter (`always_on`, `glob` with `globs`, `model_decision` with `description`, or `manual`). Antigravity also reads a `.agents/rules/` directory in any subdirectory and loads it for files under that directory ([rules docs](https://antigravity.google/docs/rules)), so in project scope a non-root rule carrying `agentsmd.subprojectPath` (explicit or derived from `globs`) is written to `<subprojectPath>/.agents/rules/` instead (the root rule and global rules are never nested). Antigravity reads only the direct children of a rules directory, so a nested rule's file name is flattened and keeps its case (`frontend/codeStyle.md` → `frontend-codeStyle.md`), and both targets write the same file. A rule that also targets `agentsmd` gets a `<subprojectPath>/AGENTS.md` as well, which Antigravity reads too. On import, the files directly inside `.agents/rules/` folders in subdirectories are read back as rules with that `agentsmd.subprojectPath` and `targets: [\"antigravity-ide\", \"antigravity-cli\"]`; a folder under a hidden directory, `node_modules`, a build output or a git-ignored directory is skipped, and symlinks are not followed. Two files with the same name in different rules directories (two subprojects, or a subproject and the project root) map to the same `.rulesync/rules/` file and collide, which import reports. Like other nested outputs, these files sit outside the root `.agents/rules/`, so `generate --delete` does not remove them. The `antigravity-plugin` target keeps every rule in its own `rules/` directory.\n\n> **Junie note:** Junie CLI resolves project guidelines in order — `.junie/AGENTS.md` → root `AGENTS.md` combined with `.junie/playbook.md` and every `.junie/rules/*.md` → the legacy `.junie/guidelines.md` / `.junie/guidelines/`. The multi-file branch is unreachable whenever `.junie/AGENTS.md` exists, because that file \"is used exclusively and no other guidelines files are combined with it\". Rulesync therefore writes the root rule to `.junie/AGENTS.md` (project) / `~/.junie/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file — lossless, since Junie loads it in full. Emitting `.junie/rules/*.md` beside it would produce files Junie never reads, and moving the root output to the project-root `AGENTS.md` would change every existing output path and collide with the `agentsmd` target, so the fold stays. The multi-file branch **is** read on import, but only while `.junie/AGENTS.md` is absent — exactly when Junie itself reads it. Hand-authored `.junie/rules/*.md` (that directory itself, not a tree below it) and `.junie/playbook.md` are then imported as non-root rules. They are import-only read roots — Rulesync never generates them, never deletes them as orphans, and never adds them to `.gitignore` — so the `generate` that follows folds their content into `.junie/AGENTS.md` and leaves the originals untouched. Later imports skip them with a single warning naming up to ten skipped files (the rest are folded into an `and N more` count): re-reading rules the root file already contains would fold the same content in again on every import/generate cycle. Only `.junie/AGENTS.md` closes that gate — the legacy `.junie/guidelines.md` does not, since Junie ranks it _below_ the multi-file branch and Rulesync never writes it, so nothing has been folded into it. In that legacy layout both branches are imported at once, so a `.junie/rules/overview.md` and the legacy root claim the same `.rulesync/rules/overview.md`; the root file wins, and the usual collision warning names the file that lost. Delete them once you have checked the fold, since Junie stops reading them the moment `.junie/AGENTS.md` exists. In that pre-`.junie/AGENTS.md` layout Junie's root file is the project-root `AGENTS.md`, which belongs to the `agentsmd` target. `rulesync import` takes one target at a time, so run `rulesync import --targets agentsmd` before `rulesync import --targets junie` if you want a root rule as well: a `junie`-only import of that layout yields non-root rules alone, and the next `generate` writes `.junie/AGENTS.md` — at which point Junie stops reading the project-root `AGENTS.md` whose content was never imported. The legacy `.junie/guidelines.md` is still accepted as an import fallback. Earlier Rulesync versions emitted non-root rules to `.junie/memories/*.md`, which is not a documented Junie read path; those files are no longer generated (stale outputs stay gitignored but are not cleaned up automatically). See the [Junie guidelines docs](https://junie.jetbrains.com/docs/guidelines-and-memory.html) and the [Junie IDE plugin docs](https://junie.jetbrains.com/docs/junie-ide-plugin.html) — the IDE plugin page is where the exclusivity sentence quoted above appears.\n\n> **Reasonix note:** Reasonix auto-injects a hierarchical instruction document, reading its vendor-specific `REASONIX.md` (alongside the cross-tool `AGENTS.md`/`CLAUDE.md`) by walking user-home → ancestors → project root/local. Rulesync writes the vendor `REASONIX.md` at the project root (project) / `~/.reasonix/REASONIX.md` (global, via `--global`) and folds non-root rules into that single file, since Reasonix has no modular rules directory. A `localRoot: true` rule goes to `./REASONIX.local.md` instead (project scope only): Reasonix loads that personal variant beside each `REASONIX.md` in the workspace chain, ahead of it (`LocalDocumentNames` in `internal/instruction/resolver.go`), so the personal body stays out of the committed file; the derived `.gitignore` lists it, and `rulesync import` reads it back as a reasonix-scoped `localRoot` rule. The Reasonix home loads no local variant, so global scope has none. Directory-scoped rules are the exception: Context Engine v2 (v1.18.0) also walks from the workspace root to the target path loading per-directory instruction files (“Deeper directories beat broader directories”), so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/REASONIX.md` (project scope only) instead of being folded — its paragraphs load only under that path rather than being carried on every turn. On **import**, nested `REASONIX.md` files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`) and land in `.rulesync/rules/<directory-with-hyphens>-reasonix.md` with `targets: [\"reasonix\"]` and the `subprojectPath` carried, so the next generate puts them back. The `-reasonix` suffix and the reasonix-only targeting keep them from clobbering the AGENTS.md standard's derived names or surprising other tools with new nested files; note that a rule targeting both `agentsmd` and `reasonix` with a `subprojectPath` produces a nested `AGENTS.md` **and** a nested `REASONIX.md` in the same directory, both of which Reasonix loads — scope such rules to one target. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md) and [Context Engine v2 docs](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.18.0/docs/SESSION_MEMORY_RETRIEVAL.md).\n\n> **Vibe Code note:** Vibe reads the project-root `AGENTS.md` (project) / `~/.vibe/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Vibe's harness manager walks the directories between the workspace root and the file being read and loads every `AGENTS.md` it finds along the way (`find_subdirectory_agents_md`), injecting the result into the `read_file` tool's output since v2.19.1 — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`). Unlike the Reasonix note above, the imported rule is **not** suffixed or target-scoped: Vibe's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]` — importing the same file through `agentsmd` and `vibe` therefore yields one rulesync rule, not two copies that would fold duplicated content back into the same `AGENTS.md`. Because the emitted file is the plain per-directory `AGENTS.md`, a rule scoped with `targets: [\"vibe\"]` is still picked up by every other AGENTS.md reader working in that directory — Vibe's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/harness_files/_harness_manager.py`).\n\n> **Meta Muse Code note:** Muse Code walks up from the working directory to the `.git` boundary and loads one instruction file per directory level, preferring `AGENTS.md` over `CLAUDE.md` when both exist. The `musecode` target writes the root rule to the shared project-root `AGENTS.md` (the same file `agentsmd`, `codexcli` and others write) and folds non-root rules into it, since Muse Code has no modular rules directory. Muse Code has user/global rules, but their path is not documented, so the `musecode` rules target is project-scope only. See the [Muse Code configuration docs](https://dev.meta.ai/docs/muse-code/configuration.md).\n\n> **ZCode note:** ZCode (Z.ai's agentic development environment for the GLM family) reads exactly two instruction files — the user-global `~/.zcode/AGENTS.md` and the workspace `AGENTS.md` at the project root — and appends them in that order. Its docs are explicit that it \"does not merge multiple `AGENTS.md` files across directory levels\" and \"does not scan child directories\", so there is no nested rules surface to emit: rulesync writes the project-root `AGENTS.md` (project) / `~/.zcode/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file. `CLAUDE.md` is deliberately not written for `zcode`: ZCode reads it only once, during onboarding, as a migration source. See the [ZCode AGENTS.md docs](https://zcode.z.ai/en/docs/agents).\n\n> **OpenClaw note:** OpenClaw (the always-on assistant gateway) injects its agent workspace's `AGENTS.md` into every session — it is also the only bootstrap file sub-agent sessions receive — and, when a session runs from another folder or a managed worktree, appends that folder's `AGENTS.md` as project context. Rulesync writes the project-root `AGENTS.md` (project) / `~/.openclaw/workspace/AGENTS.md` (global, via `--global` — only the default workspace; `OPENCLAW_WORKSPACE_DIR`, `OPENCLAW_PROFILE`, `OPENCLAW_STATE_DIR`, `agents.defaults.workspace` and per-agent `agents.entries.*.workspace` settings that relocate it are not followed) and folds non-root rules into that single file, since OpenClaw documents no per-directory walk (when OpenClaw runs on the native Codex harness, Codex's own project-doc discovery reads the execution folder's `AGENTS.md` instead). OpenClaw seeds the workspace `AGENTS.md` with its own template on onboarding, and `rulesync generate --global` replaces that file wholesale — run `rulesync import --targets openclaw --global` first if you want to keep its content. The workspace's other prompt files (`SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`) are never touched. OpenClaw truncates each bootstrap file past `agents.defaults.bootstrapMaxChars` (20,000 characters by default) and all bootstrap files together past `agents.defaults.bootstrapTotalMaxChars` (60,000 by default), and injects a short missing-file marker when `AGENTS.md` is absent — so a `--delete --global` run that no longer produces any rule for `openclaw` removes the file and leaves the agent without its operating instructions. For skills, the execution folder's `.agents/skills/` and the personal `~/.agents/skills/` are served by the `agentsskills` target; the workspace-local `~/.openclaw/workspace/skills/` and `~/.openclaw/workspace/.agents/skills/` roots are not written. See the [OpenClaw system prompt](https://docs.openclaw.ai/concepts/system-prompt), [agent workspace](https://docs.openclaw.ai/concepts/agent-workspace) and [skills](https://docs.openclaw.ai/tools/skills) docs.\n\n> **Codex CLI note:** Codex CLI reads the project-root `AGENTS.md` (project) / `~/.codex/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file, since it has no modular rules directory (`.codex/memories/` belongs to its separate auto-memory system). Directory-scoped rules are the exception: Codex loads one `AGENTS.md` per directory from the project root down to the working directory, deeper files later in the prompt — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. A rule scoped only by `globs` (e.g. `[\"services/svc1/**\"]`) is still folded unless you opt in with `agentsmd.subprojectPath: \"auto\"` on the rule or `deriveSubprojectPathFromGlobs: true` in `rulesync.jsonc` (see the AGENTS.md standard note above for how the directory is derived). Nesting matters for size: Codex stops reading project `AGENTS.md` files once their combined size on the root-to-working-directory chain reaches `project_doc_max_bytes` (32 KiB by default) and drops the rest without telling you, so `rulesync generate` warns in project scope when the generated root `AGENTS.md` — or the root plus the nested files on one directory chain — exceeds 32,768 bytes (measured per output root, so files Codex would also load from directories above an output root are not counted). Rulesync cannot author `project_doc_max_bytes` yet; if you raised it in `.codex/config.toml` yourself, the warning still uses the default and can be ignored. The personal `~/.codex/AGENTS.md` is loaded outside that budget and is not checked. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`, so a nested file stays on disk after you drop the rule's `subprojectPath` — remove it by hand) and land in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`, exactly as described in the Vibe Code note above — and, likewise, a nested file emitted from a rule scoped with `targets: [\"codexcli\"]` is still read by every other AGENTS.md reader working in that directory. See the [Codex AGENTS.md guide](https://learn.chatgpt.com/docs/agent-configuration/agents-md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) reads the project-root `AGENTS.md` (project) / `~/.config/poolside/AGENTS.md` (global, via `--global` — only the XDG-default path; Pool itself honours `XDG_CONFIG_HOME` upstream) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Pool loads every `AGENTS.md` from the repository root down through the working directory, deeper files taking precedence — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`). Like the Vibe Code note above, the imported rule is **not** suffixed or target-scoped: Pool's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`. Because the emitted file is the plain per-directory `AGENTS.md`, a rule scoped with `targets: [\"pool\"]` is still picked up by every other AGENTS.md reader working in that directory — Pool's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See the [Pool AGENTS.md instructions](https://docs.poolside.ai/agent-instructions).\n\n> **DeepSeek Harness note:** DeepSeek Harness (`dsh`, DeepSeek's open-source agent harness) loads the user-global `$DSH_HOME/AGENTS.md` and then the project chain of instruction files from the project root (the nearest ancestor holding `.git`) down to the session cwd, broad to specific. Rulesync writes the project-root `AGENTS.md` (project) / `~/.dsh/AGENTS.md` (global, via `--global` — only the `$DSH_HOME` default path) and folds non-root rules into that single file, since the harness has no modular rules directory; the user-global file has no `.local.md` overlay upstream, so global scope is root-only. Directory-scoped rules are the exception: a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded, because the harness loads it only while working under that directory. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`), and like the Pool note above the imported rule is **not** suffixed or target-scoped — it is literally the AGENTS.md standard's per-directory file, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`. The harness also reads `CLAUDE.md` and the `AGENTS.local.md` / `CLAUDE.local.md` overlays at every level of the project chain, collapsing a sibling that duplicates its `AGENTS.md`; those are the `claudecode` target's files, and rulesync's `dsh` target emits `AGENTS.md` only. DeepSeek Harness is a pre-1.0 developer preview, so these paths may move before its first stable release. See the [agent-instructions README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md).\n\n> **Claude Code root file note:** Claude Code reads `AGENTS.md` instead of `CLAUDE.md` when the working directory (or any directory above it) has no `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md`, while `.claude/rules/*.md` keeps loading alongside `AGENTS.md` ([Claude Code memory docs](https://code.claude.com/docs/en/memory#when-claude-code-reads-agents-md)). When another target such as `agentsmd` or `codexcli` already writes the project-root `AGENTS.md`, set `\"includeRoot\": false` on the `claudecode` target's `rules` options to skip the root `CLAUDE.md` and the personal `CLAUDE.local.md` while still generating `.claude/rules/*.md`. With `delete` enabled, a `CLAUDE.md` or `CLAUDE.local.md` left over from an earlier run at the project root is removed; a hand-authored `.claude/CLAUDE.md` is never deleted, but a warning points out that it still shadows `AGENTS.md`. Without `delete`, nothing is removed: a warning lists every project-root `CLAUDE.md` / `CLAUDE.local.md` and `.claude/CLAUDE.md` that still exists, and you must remove them by hand for Claude Code to read `AGENTS.md`. A `root: true` or `localRoot: true` rule whose `targets` name only `claudecode` is dropped with a warning naming the file, since no other target generates it; add the target that writes `AGENTS.md` to its `targets` or turn it into a non-root rule. `ruleDiscoveryMode` has no effect with this option, since the references it adds are written into `CLAUDE.md`. The option applies to project scope only: in global mode `~/.claude/CLAUDE.md` does not shadow `AGENTS.md`, so it is still generated (with a warning). Keep it `true` (the default) when some of your Claude Code sessions do not read `AGENTS.md` — `AGENTS.md` support arrived in v2.1.277 and is unavailable in some setups; see [when AGENTS.md support is unavailable](https://code.claude.com/docs/en/memory#when-agents-md-support-is-unavailable). Example:\n>\n> ```jsonc\n> // rulesync.jsonc\n> {\n> \"targets\": {\n> \"agentsmd\": [\"rules\"],\n> \"claudecode\": { \"rules\": { \"includeRoot\": false } },\n> },\n> }\n> ```\n\n> **CodeBuddy Code note:** CodeBuddy Code (Tencent Cloud's terminal coding agent, `@tencent-ai/codebuddy-code`) mirrors Claude Code's configuration surface closely. Rulesync writes the root rule to `CODEBUDDY.md` at the project root (also read from `.codebuddy/CODEBUDDY.md` as an alternative root) / `~/.codebuddy/CODEBUDDY.md` (global, via `--global`), and non-root rules to `.codebuddy/rules/*.md` (project) / `~/.codebuddy/rules/*.md` (global), auto-loaded without needing a reference section. A personal, gitignored `CODEBUDDY.local.md` is materialized next to the root file, mirroring Claude Code's `CLAUDE.local.md`. Non-root frontmatter supports `description`, `paths` (glob patterns gating when the rule is loaded, accepted as a single string or a list; a string is split on the commas between patterns, leaving `{a,b}` brace groups intact, so the documented `paths: {src,lib}/**/*.ts, tests/**/*.test.ts` form imports as two globs), `alwaysApply` and `enabled`, closer to the Cursor rule model than Claude Code's `paths`-only schema; put tool-specific overrides in the `codebuddy:` block of the canonical rule. Unlike Cursor, CodeBuddy's documented `alwaysApply` default is **`true`**: `paths` only scopes a rule when `alwaysApply: false` sits alongside it, and `alwaysApply: false` with no `paths` makes CodeBuddy drop the rule entirely. Rulesync applies those rules for you — a rule with non-universal globs is emitted with an explicit `alwaysApply: false`, and an `alwaysApply: false` with nothing to trigger on is left implicit rather than written out. The shipped CLI is looser than its docs table and already treats `paths` without `alwaysApply` as a path-triggered rule, so **import** follows the CLI: such a file becomes a rule scoped to those globs, and `alwaysApply` is carried over only when the file spells it out. `enabled: false` stops CodeBuddy loading the rule at all and survives an import/generate round trip. See the [CodeBuddy Code memory docs](https://www.codebuddy.ai/docs/cli/memory) and the [`.codebuddy` directory docs](https://www.codebuddy.ai/docs/cli/codebuddy-dir).\n\n> **IBM Bob note:** IBM Bob (IBM's agentic coding assistant, available as an IDE extension and the Bob Shell CLI) reads the project-root `AGENTS.md` as its primary instruction file and every Markdown file under `.bob/rules/` as additional always-on rules; the user-level counterparts are `~/.bob/AGENTS.md` and `~/.bob/rules/`. Rulesync writes the root rule to `AGENTS.md` (project) / `~/.bob/AGENTS.md` (global, via `--global`) and non-root rules to `.bob/rules/*.md` (project) / `~/.bob/rules/*.md` (global). Bob rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the [rules docs](https://bob.ibm.com/docs/ide/configuration/rules).\n\n> **JetBrains Junie note:** Junie runs a custom slash command only once every `$name` argument in its template is provided, so the universal `$ARGUMENTS` is translated to Junie's free-form `$prompt` argument and `allowPromptArgument: true` is added to the generated `.junie/commands/*.md` frontmatter (and reversed on import — see [Command Syntax](./command-syntax.md) for the exact conditions). Set `junie.allowPromptArgument` in the rulesync frontmatter to override the derived value; `false` keeps `$ARGUMENTS` verbatim. Other keys in the `junie` block are passed through to the Junie frontmatter. See the [custom slash commands docs](https://junie.jetbrains.com/docs/custom-slash-commands.html).\n\n> **Tabnine CLI note:** The file-based Tabnine CLI is in maintenance mode since Tabnine 6.6 (2026-09-15; \"Tabnine CLI (Legacy)\", `tabnine-legacy`, critical updates only through 2026-12-31), and the new `tabnine` command is an OpenCode distribution covered by `--targets opencode` — see [Supported tools > Deprecation notes](./supported-tools.md#deprecation-notes). The legacy Tabnine CLI loads the project-root `TABNINE.md` as its memory file on every session (refreshed with `/memory reload`), so Rulesync writes the root rule there. Non-root rules are written to `.tabnine/guidelines/*.md`, the guideline directory the Tabnine agent reads; the CLI does not load that directory on its own, so `TABNINE.md` also lists the guideline files as a table of contents (the same table-of-contents mode used for tools whose non-root rules are not auto-loaded). In global mode (`--global`) the root rule goes to `~/.tabnine/agent/TABNINE.md` — the user-level memory file the [migration guide](https://docs.tabnine.com/main/getting-started/tabnine-plugin-for-opencode/migrating-from-tabnine-cli) names as \"the global `~/.tabnine/agent/TABNINE.md` file\", next to the other user-scoped agent assets under `~/.tabnine/agent/` — and non-root rules to `~/.tabnine/guidelines/*.md`. Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. Because `.tabnine/guidelines/` is a directory Tabnine defines rather than one Rulesync invented, a project may already have hand-written guidelines there: Rulesync owns it from now on, so `--delete` removes anything in it — `~/.tabnine/guidelines/` included, in global mode — that `.rulesync/rules/` does not produce. Move those files into `.rulesync/rules/` first. See the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference) (`context.fileName`) and the [guidelines docs](https://docs.tabnine.com/main/getting-started/tabnine-agent/guidelines).\n\n> **Snowflake Cortex Code note:** Snowflake Cortex Code (the `cortex` CLI, also called \"CoCo\") reads the project-root `AGENTS.md` as its instruction file. It documents no modular rules directory and no user-level instruction file, so Rulesync folds every non-root rule into the single `AGENTS.md` (the same fold mode Codex CLI uses for rules that are not directory-scoped) and the `rules` feature is project-only (`--global` is not supported). Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the [Cortex Code overview](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code).\n\n> **Codewhale note:** Codewhale reads the workspace-root `AGENTS.md` as its project instruction file and then every `.codewhale/rules/*.md` file in filename order, so Rulesync writes the root rule to `AGENTS.md` and each non-root rule to `.codewhale/rules/<name>.md`. Codewhale does not walk into nested subproject `AGENTS.md` files, so `agentsmd.subprojectPath` is not honored. In global mode (`--global`), Codewhale prepends `~/.codewhale/AGENTS.md` to the project instructions and has no user-level rules directory, so every global rule is folded into that single file.\n\n> **Continue note:** Continue is end of life (it joined Cursor on 2026-06-18; the repository is read-only after the final v2.0.0 release), so the `continue` target is frozen at that final source tree — see [Supported tools > Deprecation notes](./supported-tools.md#deprecation-notes). Continue (the open-source IDE extension and `cn` CLI) reads the project-root `AGENTS.md` as an always-applied instruction file and every Markdown file under `.continue/rules/` (project) / `~/.continue/rules/` (global, via `--global`) as a rule with optional YAML frontmatter, so Rulesync writes the root rule to `AGENTS.md` and non-root rules to `.continue/rules/*.md`. Continue documents no user-level `AGENTS.md`, so in global mode the root rule is written as `~/.continue/rules/AGENTS.md` and told apart from the other rules by its basename (the convention the Roo Code target also uses). A non-root rule carries `description`, `globs` and the Continue-specific `regex` / `alwaysApply` keys: a rule with no `globs`/`regex` is always applied, one with `globs` is applied when a file in context matches, `alwaysApply: true` forces it on and `alwaysApply: false` makes it depend on `globs`/`regex` only. The universal globs `**/*` and `*` are therefore dropped on generate (a globless rule is already always-on), as is any glob next to `alwaysApply: true`. The `continue` frontmatter block (`name`, `alwaysApply`, `regex`, plus `globs` and `description` overrides of the canonical keys) authors those fields; on import a globless always-on rule maps to `globs: [\"**/*\"]`, `alwaysApply: false` without `globs` maps to `globs: []`, and `name` / `alwaysApply` / `regex` are lifted into the `continue` block. See the [rules docs](https://docs.continue.dev/customize/deep-dives/rules).\n\n> **Command Code note:** Command Code (the open-source terminal coding agent published as `command-code` on npm) reads the project-root `AGENTS.md` as its instruction file and the user-level `~/.commandcode/AGENTS.md` (global, via `--global`). An `AGENTS.md` in a subdirectory is only loaded lazily once the agent touches a file beneath it, so there is no deterministic nested rules surface and Rulesync folds every non-root rule into the single `AGENTS.md` (the same fold mode Codex CLI uses for rules that are not directory-scoped). The file is plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and only the file name survives an import. See the [memory docs](https://commandcode.ai/docs/memory).\n\n> **Qoder note:** Qoder (Alibaba's agentic coding platform: the Qoder IDE, the `qodercli` terminal agent and the JetBrains plugin, which share one `.qoder/` layout) reads the project-root `AGENTS.md` plus the per-machine `AGENTS.local.md` overlay (written for a `localRoot: true` rule), and auto-loads the topic rules under `.qoder/rules/*.md`; the user scope is `~/.qoder/AGENTS.md` and `~/.qoder/rules/*.md` (global, via `--global`). Rulesync writes the root rule as plain Markdown to `AGENTS.md` and each non-root rule to `.qoder/rules/<name>.md` with a `trigger` frontmatter: specific `globs` become `trigger: glob` with a `glob` list, and a rule with no globs or a match-everything glob (`**/*`) becomes `trigger: always_on`. A `description` is carried along. Set `qoder.trigger` (`always_on`, `glob`, `manual` or `model_decision`, where Qoder needs a non-empty `description`) and optionally `qoder.glob` in the rulesync frontmatter to pick the mode yourself; a `glob` trigger without specific globs is written as `always_on` with a warning, and a `model_decision` trigger without a description is written with a warning. On import the compatibility spellings are read too (`alwaysApply: true` / `false` as `always_on` / `manual`, as Qoder documents them, a bare `paths` or `glob` list as `glob`); a `manual` or `model_decision` rule keeps its mode in the `qoder` block, and so does any frontmatter key rulesync does not map. A `glob` rule with an empty glob list, which Qoder never applies, is imported with `qoder.trigger: glob` and a warning; the next generate writes it as `always_on`, so add `globs` or set `qoder.trigger: manual` to keep it inactive. See the [memory docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/memory).\n\n## `.rulesync/hooks.jsonc`\n\n`.rulesync/hooks.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/hooks.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nA key named `__proto__`, `constructor`, or `prototype` is rejected with an error naming its path, rather than being dropped in silence. The \"Rejected keys\" paragraph in the `.rulesync/permissions.jsonc` section below describes this handling, which is the same for all three source files.\n\nHermes Agent accepts native snake-case events under `hermesagent.hooks`: `pre_tool_call`, `post_tool_call`, `transform_terminal_output`, `transform_tool_result`, `transform_llm_output`, `pre_llm_call`, `post_llm_call`, `on_stream_start`, `on_stream_delta`, `on_stream_end`, `on_interim_message`, `pre_verify`, `pre_api_request`, `post_api_request`, `api_request_error`, `pre_auxiliary_call`, `post_auxiliary_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `on_skill_lifecycle`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `post_gateway_admission`, `agent_loop_stopped`, `pre_approval_request`, `post_approval_response`, `on_human_input_request`, `on_human_input_resolved`, `on_room_member_activity`, `pre_transcription`, `kanban_task_claimed`, `kanban_task_completed`, `kanban_task_blocked`, `on_kanban_worker_spawned`, `on_kanban_worker_exited`, `on_kanban_worker_stale_claim`, `on_kanban_task_updated`, `on_kanban_dispatch_tick`, `gateway_platform_event`, and `pre_command`. That is 43 of the 44 `VALID_HOOKS` entries at v0.21.6 (`agent_loop_stopped` and `on_room_member_activity` arrived in v0.21, `pre_auxiliary_call` and `post_auxiliary_call` in v0.21.5, `post_gateway_admission`, `on_human_input_request` and `on_human_input_resolved` in v0.21.6): `transform_api_error_classification` is subtracted by `SHELL_UNSUPPORTED_HOOKS`, because a shell hook cannot return its directive and Hermes refuses the registration — authoring it still emits the entry, with a warning saying it will never run. Rulesync maps shared canonical events first, applies canonical keys from `hermesagent.hooks` next, then applies exact native keys last. An exact native key therefore wins when both forms resolve to the same Hermes event. Native-only events remain under `hermesagent.hooks` on import instead of leaking into other targets. Rulesync owns the event keys inside the `hooks:` mapping of `config.yaml`, but not the mapping itself: Hermes v0.20.0 nests the [outbound webhook registry](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) under the same key as `hooks.outbound`, so any key there that is not a Hermes hook event is carried over from the existing file untouched. Rulesync neither authors nor imports `outbound`, since it is a list of webhook targets rather than a hook event; it only makes sure a regenerate leaves it alone. An event key Rulesync did write, including one under an undocumented event name supplied through `hermesagent.hooks`, is still retracted when it disappears from the source.\n\nHooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Qwen Code, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`) — both share one event surface apart from `notification` (see below), in which `preToolUse`/`postToolUse` become named `tool.execute.before`/`tool.execute.after` hooks, `preCompact` becomes the named `experimental.session.compacting` hook, `beforeSubmitPrompt` the named `chat.message` hook and `preModelInvocation` the named `chat.params` hook (fired before every LLM request, for subagents too) — all three receive `(input, output)` and expose nothing to match on, so a `matcher` on any of them is dropped — while `userPromptExpansion` becomes the named `command.execute.before` hook, which fires only for slash commands (on the expanded prompt, right before it is submitted) and tests a `matcher` against the command name (`input.command`), `beforeShellExecution`/`afterShellExecution` also land in those named `tool.execute.*` hooks with an implicit `input.tool === \"bash\"` gate — OpenCode has no shell-execution lifecycle event (`command.executed`, which earlier Rulesync versions mapped `afterShellExecution` to, is a _slash-command_ event, so the hook never fired on shell commands; regenerate to fix), and matchers on the shell events are dropped with a warning since the named hooks expose no command text, and the rest are `event.type` dispatches — `sessionStart` → `session.created`, `stop` → `session.idle`, `afterFileEdit` → `file.edited`, `permissionRequest` → `permission.asked`, `permissionDenied` → `permission.replied` (which fires for every reply, so the generated handler is gated on `event.properties.reply === \"reject\"`), `notification` → `tui.toast.show` (**OpenCode only** — Kilo's plugin docs document no TUI events, so `notification` is not part of its surface; note too that OpenCode's toast channel is broader than the canonical event, since every `info`/`success`/`warning`/`error` toast fires the hook rather than only the ones asking for your attention, and most toasts originate in the TUI client, so a headless `opencode run` rarely fires it at all), `postCompact` → `session.compacted`, `afterError` → `session.error`, `fileChanged` → `file.watcher.updated`, `sessionDelete` → `session.deleted`; Amp hooks are emitted as a TypeScript plugin (`.amp/plugins/rulesync-hooks.ts`, or `~/.config/amp/plugins/rulesync-hooks.ts` in global mode) using `session.start`, `tool.call`, `tool.result`, `agent.start`, and `agent.end` (plus the Amp-only `changes.prompt` from the `amp.hooks` override block); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_before_settle` (added in Pi v0.87.0, so it needs Pi v0.87.0 or later — older Pi never fires it and stop hooks silently do not run there), `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name, `preCompact` → `session_before_compact`, `postCompact` → `session_compact`, `postModelInvocation` → `message_end` gated on assistant messages so it runs once per finalized model response, `beforeSubmitPrompt` → `input`, `postToolUse` → `tool_result`, `postToolUseFailure` → the same `tool_result` gated on `event.isError` — Pi fires one event for every finished tool call and flags the failed ones, so the two canonical events share one subscription and only the failure handlers check the flag; `postToolUse` keeps firing for failed calls too, as it always has on Pi — and `notification` → `ui_prompt_start` (added in Pi v0.84.4), Pi's \"waiting for user\" signal: it fires when an extension opens a blocking `ctx.ui.select` / `confirm` / `input` / `editor` / `custom` prompt, so it covers prompts raised by extensions — Rulesync's own or third-party ones — and never core Pi, which has no built-in permission popup; it is notification-only (handlers are best-effort and not awaited, so the hook command can neither delay nor cancel the prompt), nested prompts coalesce into one span so it fires once per span, and a `matcher` on it is dropped because the handler tests nothing against it) — `tool_call` is Pi's only tool gate, so a `preToolUse` command that exits non-zero denies the call with `{ block: true, reason }` (the reason is the command's stderr, falling back to its stdout and then to its exit code, with terminal escape sequences, control and C1 characters, and format characters such as bidirectional overrides and zero-width joiners stripped, carriage returns folded into newlines so the text cannot overwrite an already printed line, the sanitized result capped at 2000 characters (and only the first 128000 characters of the command's output scanned at all) — sanitizing first so a reason that opens with a progress banner is still reported by its content — and a generic `Hook command failed.` used when nothing survives), and `input` is its prompt-submission gate, so a `beforeSubmitPrompt` command that exits non-zero cancels the prompt with `{ action: \"handled\" }` — that result carries no reason field, so the same text is reported through `ctx.ui.notify`, falling back to stderr in print (`-p`) and JSON modes where that channel is a no-op; three limits are worth knowing before relying on the prompt gate: Pi checks extension slash commands before the `input` event, so input consumed as a `/cmd` never reaches the gate, messages another extension injects via `sendUserMessage` (`event.source === \"extension\"`) are deliberately passed through because the canonical event covers prompts the user submits, and the hook command receives no prompt text (Pi's `input` event exposes it, but the generated handler runs the command with no stdin or arguments, unlike Claude Code's `UserPromptSubmit`), so the gate can only decide from ambient state; a `matcher` on `beforeSubmitPrompt` is dropped, since `input` exposes no tool name to test it against; `agent_before_settle` is Pi's last actionable boundary before a run settles (it fires after automatic retries and compaction, so a stop hook runs once per settle, unlike the notify-only `agent_end` earlier Rulesync versions mapped `stop` to), so a `stop` command that exits with code 2 asks the agent to keep going like a blocking Claude Code `Stop` hook (any other non-zero exit, including a command that cannot be run, never continues the agent, as Claude Code treats it as a non-blocking error; the handler rethrows it after the remaining stop commands run, so Pi reports it as an extension error): every configured stop command runs, and the same sanitized text from each command that exited with 2 is combined into one displayed `custom_message` (`Stop hook feedback: …`) appended after any entries other extensions proposed, with `continue: true` for one more model request — note that this sends the command's output to the model, so do not print secrets from a stop hook; to avoid the loop Pi warns about, this happens at most once per run (the guard resets on `agent_settled`, so the next prompt may continue again; later failures in the same run are only reported), never for a run that was aborted or ended in an error, and the hook command receives no event payload, so there is no equivalent of Claude Code's `stop_hook_active` to check; every other event, `postToolUse`/`tool_result` included, observes only and cannot block or mutate Pi events; a denied call deliberately leaves Pi's `terminate` flag unset so control returns to the model instead of ending the turn; Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and write the command into the `bash`/`powershell` field named by the canonical `shell` selector, or into the portable `command` field when none is set — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli gets the Hooks v2 PascalCase `HookEvent` names (e.g. `SessionStart`, `PostToolUseFailure`) in a `{ \"hooks\": { \"<Event>\": [{ \"matcher\": …, \"hooks\": [{ \"type\": \"command\", … }] }] } }` document — this requires deepagents-code 0.1.52+, the release where Hooks v2 became generally available (the legacy flat list is removed upstream on 2026-09-01; Rulesync still imports the legacy format but no longer writes it); `kiro-cli` and `kiro-ide` emit hooks into the standalone `.kiro/hooks/rulesync.json` with PascalCase triggers, while the deprecated `kiro` alias still writes them into `.kiro/agents/default.json` using the older event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's); ZCode emits PascalCase events into the `events` map nested under the `hooks` key of `.zcode/config.json` (project, the same file that holds its MCP servers, whose `mcp` key is preserved) or `~/.zcode/cli/config.json` (global, via `--global`) — ZCode runs workspace hooks only after each user trusts them (new or changed workspace hooks wait for approval in ZCode's review prompt or via `zcode hooks trust review` / `grant`, and headless runs skip untrusted ones with `workspace_hooks_pending_trust`), so any change to the generated hooks asks every teammate to trust them again; ZCode's hooks docs page still says workspace hooks are ignored, but the v3.8.1 changelog announces workspace-level hooks and the v3.14.3 runtime executes trusted ones — seven events in all, matcher-capable except on `UserPromptSubmit` and `Stop` (which expose no value to match against), with commands emitted verbatim — note that ZCode reads a matcher of only letters, digits, underscores and `|` as an exact name list (`Write|Edit` matches those two tool names) and anything else as a case-sensitive JavaScript regex, and Rulesync passes the value through unchanged; it states `hooks.enabled: true` when it writes events and the file states no `enabled` preference, because ZCode runs no configuration hooks while that switch is off (any authored `enabled` value, including a deliberate `false`, is left alone as the user's switch) — note that ZCode's master switch is on when any config source states `enabled: true`, so a committed project `.zcode/config.json` with `enabled: true` can also activate a teammate's user-level hooks whose `~/.zcode/cli/config.json` leaves `enabled` unset inside that project (a source that states `enabled: false` keeps its own hooks off); ZCode's native `process` hook type has no canonical equivalent, so those entries are skipped with a warning on import.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) hooks are emitted as the same kind of Rulesync-owned TypeScript extension as Pi's, written to `.omp/extensions/rulesync-hooks.ts` (project) / `~/.omp/agent/extensions/rulesync-hooks.ts` (global, via `--global`; default profile only, so a named profile or a `PI_CODING_AGENT_DIR` override is not followed). oh-my-pi loads extension modules from those directories itself; Rulesync does not use the legacy `.omp/hooks/pre|post/` hook directories, since one module subscribes to both pre- and post-tool events. The generated module imports its types from `@oh-my-pi/pi-coding-agent` and follows the Pi mapping above for `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `preModelInvocation`, `postModelInvocation`, `beforeSubmitPrompt`, `preCompact` and `postCompact`, with the same matcher, blocking and reason-sanitizing behavior, except that a failing `beforeSubmitPrompt` command cancels the prompt with oh-my-pi's `{ handled: true }` result instead of Pi's `{ action: \"handled\" }`. `stop` and `notification` are not supported yet: oh-my-pi has neither `agent_before_settle` nor `ui_prompt_start`, and mapping `stop` to oh-my-pi's own `session_stop` event is not implemented. oh-my-pi fails a `tool_call` closed when its handlers exceed `extensionHandlers.toolCallTimeoutMs` (30 seconds by default), so a slow `preToolUse` command blocks the tool call, and it gives `session_shutdown` handlers only 2 seconds. The prompt gate is narrower than the tool gate: oh-my-pi fires `input` only for prompts submitted interactively or over RPC (print mode and ACP input bypass it), and an `input` handler that exceeds the 30-second handler budget lets the prompt through. Tool-scoped hooks go under an `omp.hooks` block. See the [oh-my-pi extension docs](https://github.com/can1357/oh-my-pi/blob/main/docs/extensions.md) and [extension loading docs](https://github.com/can1357/oh-my-pi/blob/main/docs/extension-loading.md).\n\nExample:\n\n```json\n{\n \"version\": 1,\n \"hooks\": {\n \"sessionStart\": [{ \"type\": \"command\", \"command\": \".rulesync/hooks/session-start.sh\" }],\n \"preToolUse\": [{ \"matcher\": \"Bash\", \"command\": \".rulesync/hooks/confirm.sh\" }],\n \"postToolUse\": [{ \"matcher\": \"Write|Edit\", \"command\": \".rulesync/hooks/format.sh\" }],\n \"stop\": [{ \"command\": \".rulesync/hooks/audit.sh\" }]\n },\n \"cursor\": {\n \"hooks\": {\n \"afterFileEdit\": [{ \"command\": \".cursor/hooks/format.sh\" }]\n }\n },\n \"claudecode\": {\n \"hooks\": {\n \"notification\": [\n {\n \"matcher\": \"permission_prompt\",\n \"command\": \"$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh\"\n }\n ]\n }\n },\n \"opencode\": {\n \"hooks\": {\n \"afterShellExecution\": [{ \"command\": \".rulesync/hooks/post-shell.sh\" }]\n }\n },\n \"copilot\": {\n \"hooks\": {\n \"afterError\": [{ \"command\": \".rulesync/hooks/report-error.sh\" }]\n }\n }\n}\n```\n\n**Top-level keys:**\n\n- `version`: Schema version (currently `1`).\n- `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event.\n- `amp.hooks`, `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `mimocode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`, `grokcli.hooks`, `zcode.hooks`, `bob.hooks`, `tabnine.hooks`, `cortexcode.hooks`, `codewhale.hooks`, `continue.hooks`, `commandcode.hooks`, `lettacode.hooks`, `qoder.hooks`, `gitlabduo.hooks`, `crush.hooks`, `pool.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI, `PostFileSave`/`PreTaskExec` for Kiro) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block.\n\nBy default the generated hooks replace the destination's hook list wholesale, so a handler another tool or a person added by hand there is lost on the next `generate`. Setting `\"preserveUnownedHooks\": true` in `rulesync.jsonc` keeps those handlers and appends the generated ones instead; see [Configuration](../guide/configuration.md). It covers Claude Code, Codex CLI and Cursor (the Claude Code plugin bundle always replaces) and records what Rulesync generated in a `.rulesync-hooks-lock.json` next to each hooks file, so a hook Rulesync wrote and the sources no longer define is still retracted.\n\n**Hook entry keys:**\n\n- `command` (required): Shell command to execute when the event fires.\n\n Write paths to project files as `./`-relative paths. For targets that anchor hook commands to a project-directory variable (Claude Code's `$CLAUDE_PROJECT_DIR`, the Claude Code plugin's `$CLAUDE_PLUGIN_ROOT`, the AugmentCode plugin's `$AUGMENT_PLUGIN_ROOT`, Command Code, Continue, Cortex Code and Factory Droid), Rulesync prefixes a command whose first word starts with `.` (but not the `.` builtin, as in `. ./env.sh`) with the variable. It also anchors later `./` words that name a file the command runs:\n\n - Anchored positions: the command word of each later command in a chain or pipeline, or inside `$( … )`, backticks or a single-quoted `-c` script of `sh`, `bash` and similar shells (combined flags such as `bash -lc '…'` included; the script's own quotes are respected); and the first non-option argument of a script runner — `.`, `source`, `sh`, `bash` (and similar shells), `node`, `bun`, `tsx`, `ts-node`, `python` (also `python3` or `python3.12`), `ruby`, `perl` or `php`, as in `node ./x.js` or `python3 \"./my x.py\"`. The same applies behind a package runner or run subcommand (`npx tsx ./x.ts`, `pnpm exec tsx ./x.ts`, `uv run ./x.py`, `uv run python ./x.py`, `bun run ./x.ts`, `deno run -A ./x.ts`), and to the value of a runner option that loads a file (`node -r ./register.js ./x.js`, `perl -I ./lib ./x.pl`). Prefix words and their options before the command word are skipped (`time -p node ./x.js`, `exec -a name ./x.sh`), as are `env` and the assignments after it (`env FOO=1 ./x.sh`), although nothing is anchored after an `env` option (`env -u NAME ./x.sh`, `env -C dir ./x.sh`); and an escaped or quoted command name (`\\node`, `\\bash -c '…'`, `\"bash\" -c '…'`) counts as the command itself; an option that takes a value, such as `python3 -W ignore ./x.py`, is skipped together with its value, and after inline code, a module, standard input or a package runner's shell mode (`python3 -c`, `python3 -m`, `node -e`, `bash -s`, `npx -c`, `pnpm exec --shell-mode`) nothing more is anchored; `yarn exec`, which runs its arguments through Yarn's own shell, is not followed either.\n - Written forms: an unquoted `./x` becomes `\"$CLAUDE_PROJECT_DIR\"/x`, a double-quoted `\"./x\"` becomes `\"$CLAUDE_PROJECT_DIR/x\"`, and a single-quoted `'./x'` becomes `\"$CLAUDE_PROJECT_DIR\"/'x'`.\n - Left as written: every other argument, whose meaning depends on the command (`npx prettier --write ./src`, a redirection target, `--config=./c.json`, the arguments of a command run through `npx` that is not a script runner, such as `npx prettier`), and anything handed to a container or another host (`docker`, `podman`, `nerdctl`, `kubectl`, `ssh`, `vagrant`). Text inside a quoted string, comments, `$'…'` strings and `../` or `.name` words are never anchored, and neither is an unquoted or double-quoted `sh -c` script, which the inner shell would split again once expanded, nor a `-c` script handed to another command (`sudo sh -c '…'`, also written `\\sudo`, or `find . -execdir sh -c '…' \\;`), which runs elsewhere if at all. A quote on the line after `sh -c` starts a new command rather than the script, unless a line continuation (`\\` at the end of the line) joins the lines.\n\n These later words are scanned as POSIX shell syntax, so a hook with `shell: \"powershell\"` only has its first word prefixed, on generate and on import alike. This applies only to targets whose hooks have a `shell` field (Claude Code, including its plugin hooks); every other target runs the hook in its own shell, so `shell` does not change what is anchored there.\n\n Import converts the variable back to `./` wherever it starts a path, so a hook such as `python3 \"$CLAUDE_PROJECT_DIR/hooks/check.py\"` becomes `python3 \"./hooks/check.py\"` and the variable does not leak into targets that never set it. It keeps the variable after the first word in a command that changes directory (`cd`, `pushd`, `popd`), uses `eval`, a heredoc or here-string (`<<`), or one of the container and remote commands above, since `./` would no longer mean the same place there; a command that starts with another variable (such as `$HOME/…`) is converted in its first word only as well, a `-c` script handed to another command (`sudo sh -c '…'`) is left as it is, and so is the value of an assignment word or option (`PATH=$CLAUDE_PROJECT_DIR/bin:$PATH`, `--config=$CLAUDE_PROJECT_DIR/c.json`), which generate never anchors; import prints a warning naming a command that keeps the variable this way. A variable in an argument that generate does not anchor (`npx prettier --write \"$CLAUDE_PROJECT_DIR\"/src`) comes back as `./src`, which resolves against the directory the hook runs in, and import prints a warning naming the command. Claude Code runs hooks in its current directory, which is a worktree or a directory Claude changed into rather than always the project root, so to keep the variable there, write the command in the `claudecode.hooks` override. A working directory changed by other means (for example `git -C dir`, `env -C dir`, `uv run --directory dir`, `pnpm -r exec`, `pnpm --filter=pkg exec` or `bun --cwd=dir run`) or by an obfuscated `cd` (such as `c\\d`) is not detected, and neither is a path in a `-c` script that follows a single quote embedded as `'\\''`.\n\n- `type` (optional): One of `\"command\"` (default), `\"prompt\"`, `\"http\"`, `\"agent\"`, `\"mcp_tool\"`, or `\"function\"` — the union of the hook types accepted across supported tools. Each tool supports a subset (most support only `command`); hooks with a type a tool does not support are skipped for that tool with a warning. See notes below.\n- `matcher` (optional): Regex used by tools that scope hooks to specific tool names (e.g. `preToolUse`, `postToolUse`, `notification`). Ignored by events that do not take a matcher (e.g. `sessionStart`, `worktreeCreate`, `worktreeRemove`).\n- `timeout` (optional): Per-hook timeout in seconds, forwarded to tools that support it.\n- `cacheTtl` (optional): Number of seconds to cache a successful hook result. Forwarded to the deprecated `kiro` alias's agent-config format as `cache_ttl_seconds`; `0` disables caching and Kiro never caches `AgentSpawn` hooks.\n- `failClosed` (optional): Boolean. When `true`, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's `.cursor/hooks.json`, to JetBrains Junie's `~/.junie/config.json` (as Junie's equivalently-named `blockOnError` flag), to Hermes Agent's `~/.hermes/config.yaml` (as `fail_closed`), and to Goose's plugin `hooks.json` (as `on_failure: \"block\"`, Goose v1.48.0+). Hermes only honours it on `pre_tool_call` (its one blocking-capable event) and Goose only reads `on_failure` on `PreToolUse`, so a `failClosed` set on any other canonical event is dropped with a warning for those targets.\n- `commandRegex` (optional): Regex applied to the shell command string, narrowing an `Execute` matcher group further (e.g. `\"^git \"`). Forwarded to Factory Droid, which skips invalid regex values. Like `matcher`, it belongs to the whole matcher group, so every hook sharing that matcher receives it.\n- `async` (optional): Boolean. When `true`, the hook command runs in the background without blocking. Forwarded to Qwen Code (`.qwen/settings.json`), JetBrains Junie (`~/.junie/config.json`, same field name), Claude Code and Codex CLI (`.codex/hooks.json`, same field name — Codex runs up to eight background hooks concurrently per session and queues the rest).\n- `env` (optional, `command` hooks): a map of extra environment variables merged into the hook process's environment. Forwarded to Qwen Code (`.qwen/settings.json`), Copilot CLI, and Grok CLI (`.grok/hooks/rulesync.json`, upstream `HookConfig.env`, merged into the spawned command's `extra_env`). Documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning). For Grok CLI, an entry whose key is empty or contains `=`, or whose key or value contains a newline, carriage return or NUL, is refused in both directions — the tool rebuilds each entry into a `KEY=VALUE` string, so such a key would name a different variable than it appears to.\n- `shell` (optional): Either `\"bash\"` or `\"powershell\"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code, Claude Code, Copilot and Copilot CLI command hooks; for the two Copilot targets it names the `bash`/`powershell` field the command is written into, and leaving it unset selects their portable `command` field. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning).\n- `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.\n- `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.\n- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks, to Qwen Code prompt hooks, and to Cursor prompt hooks (the [Cursor hooks docs](https://cursor.com/docs/hooks) document it as an optional prompt-hook override; it is dropped from Cursor `command` hooks).\n- `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code, AugmentCode and Copilot CLI (where it is written as the CLI-only `exec` + `args` pair). Only `command` is prefixed; entries of `args` are passed through exactly as written.\n- `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks.\n- `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks.\n- `continueOnBlock` (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code.\n- `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.\n- `additionalContextLimit` (optional): a non-negative integer. The token threshold above which the tool writes the hook's additional context to a file and passes that path instead of the text itself (upstream default 2500). Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.\n- `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.\n- `enabled` (optional): boolean, default `true`. Whether the hook is active. Forwarded to Kiro's standalone hooks file (`.kiro/hooks/rulesync.json`, written by the `kiro-cli` and `kiro-ide` targets) and to ZCode's per-hook `enabled` in `.zcode/config.json` / `~/.zcode/cli/config.json` and in a ZCode plugin's `hooks/hooks.json` — the places with a per-hook on-disk enable flag Rulesync writes; an imported `enabled: false` round-trips, so a deliberately disabled hook is not silently switched back on by the next generate. Every other target has no way to express it, so the hook is emitted there as an ordinary **active** hook and a warning is logged at generate time — to turn a hook off everywhere, remove it rather than setting `enabled: false`. (Antigravity has an `enabled` flag of its own, but on the named hook group rather than the individual definition, so it is not driven by this field.)\n- `confirm` (optional): `{ \"question\": \"…\", \"options\": [{ \"id\": \"…\", \"label\": \"…\", \"run\": true | false }], \"confirmCommand\": \"…\" }` — a confirmation prompt shown before the hook runs, where the chosen option's `run` decides whether the command executes. The optional `confirmCommand` inside the block names a command whose JSON stdout drives that prompt dynamically (`{ \"skip\": true }` suppresses it and skips the hook; a replacement `question`/`options` object overrides the static prompt; on failure or timeout the static prompt is used). Forwarded verbatim to Kiro's standalone hooks file (`kiro-cli` and `kiro-ide`), which honors it on `Stop`-trigger command hooks, and round-tripped on import so a hand-written prompt survives regeneration. No other target has an equivalent: they drop the block with a warning and run the hook unconditionally.\n- `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `\"Bash(rm *)\"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string.\n- `source` (optional, `command` hooks): an object `{ \"source\": \"github:<org>/<repo>/<path>\", \"ref\"?: \"<branch or commit>\" }` naming a script fetched from a Git repository (a Snowflake Cortex Code \"remote hook\"); `command` then names the interpreter the fetched script runs under, e.g. `\"bash\"`. Forwarded to Snowflake Cortex Code, which is the only tool that accepts it; on import an object without a `source` key is dropped with a warning.\n\nA field a tool documents on `command` hooks only (`args`, `env`, `shell`, `async`, `asyncRewake`, `source`) is dropped with a warning in both directions when it appears on a hook of another type — on generate for a value authored in `.rulesync/hooks.*`, and on import for one found in an existing tool config. Import additionally checks each value against the constraint the canonical field declares (e.g. `shell` must be `\"bash\"` or `\"powershell\"`, `additionalContextLimit` must be a non-negative integer, and no string field may carry a newline, carriage return or NUL); a value that fails is skipped with a warning naming the constraint, rather than imported into a file the next generate would refuse to read. When the offending value is a `command`, `prompt` or `matcher`, the whole hook is skipped instead of just that field, since a hook without its command runs nothing and a hook without its matcher fires on everything.\n\nTop-level `hooks` keys must be canonical event names; unknown event names are rejected at parse time. Tool-specific override blocks (e.g. `kiro.hooks`) additionally accept tool-native event keys, which pass through verbatim.\n\nEvents present in the shared `hooks` block but unsupported by a given tool are skipped for that tool (a warning is logged at generate time). The canonical `notification` event maps to deepagents-cli's Hooks v2 `Notification` event, whose matcher selects the notification kind (e.g. `agent_needs_input`); canonical `contextOffload` is skipped for deepagents-cli, since its legacy `context.offload` event has no Hooks v2 counterpart.\n\n### Hook event × tool matrix\n\n<!-- HOOK_EVENTS_MATRIX:BEGIN -->\n\n| Event | Amp | IBM Bob | Claude Code | Claude Code plugin | CodeBuddy Code | Codewhale | Codex CLI | Command Code | GitHub Copilot | GitHub Copilot CLI | Crush | Goose | Hermes Agent | Grok CLI | Cursor | deepagents-cli | Factory Droid | OpenCode | MiMo Code | GitLab Duo CLI | Cline | Kilo Code | Kimi Code | Tabnine CLI ⚠️ | Continue ⚠️ | Snowflake Cortex Code | Vibe Code | Vibe Code plugin | Qoder | Qwen Code | Reasonix | Kiro ⚠️ | Kiro CLI | Kiro IDE | Letta Code | Google Antigravity IDE | Google Antigravity CLI | Google Antigravity plugin | JetBrains Junie | AugmentCode | AugmentCode plugin | Devin Desktop | Devin plugin | Pi Coding Agent | oh-my-pi | ZCode (Z.ai) | ZCode plugin | Pool (Poolside) |\n| ---------------------- | :-: | :-----: | :---------: | :----------------: | :------------: | :-------: | :-------: | :----------: | :------------: | :----------------: | :---: | :---: | :----------: | :------: | :----: | :------------: | :-----------: | :------: | :-------: | :------------: | :---: | :-------: | :-------: | :------------: | :---------: | :-------------------: | :-------: | :--------------: | :---: | :-------: | :------: | :-----: | :------: | :------: | :--------: | :--------------------: | :--------------------: | :-----------------------: | :-------------: | :---------: | :----------------: | :-----------: | :----------: | :-------------: | :------: | :----------: | :----------: | :-------------: |\n| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `sessionEnd` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — |\n| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — |\n| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — |\n| `beforeSubmitPrompt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `stop` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ |\n| `subagentStop` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `preCompact` | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ |\n| `postCompact` | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — |\n| `postToolUseFailure` | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — |\n| `subagentStart` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeShellExecution` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterShellExecution` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeMCPExecution` | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterMCPExecution` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeReadFile` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterFileEdit` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterAgentResponse` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterAgentThought` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeTabFileRead` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterTabFileEdit` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `permissionRequest` | — | — | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | — | — | — | — | — | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | ✅ | — |\n| `notification` | — | — | ✅ | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — |\n| `setup` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterError` | — | — | — | — | — | ✅ | — | — | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeToolSelection` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `worktreeCreate` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `worktreeRemove` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `workspaceOpen` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `messageDisplay` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `todoCreated` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `todoCompleted` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `stopFailure` | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — |\n| `stopCancelled` | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `instructionsLoaded` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `userPromptExpansion` | — | — | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `postToolBatch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `permissionDenied` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `taskCreated` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `taskCompleted` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `teammateIdle` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `configChange` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `cwdChanged` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `fileChanged` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `directoryAdded` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `elicitation` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `elicitationResult` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `sessionDelete` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `preModelSwitch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `postModelSwitch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n\n<!-- HOOK_EVENTS_MATRIX:END -->\n\n> **Note:** `beforeSubmitPrompt`, `stop`, `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are the Claude Code events the [matcher table](https://code.claude.com/docs/en/hooks) lists as not supporting the `matcher` field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into `settings.json` to be ignored. `directoryAdded` is **not** one of them: the matcher table documents it as filtering on how the directory was added (`slash_command`, `register_repo_root`), so a matcher written on it is emitted as-is. The same holds for `preModelSwitch` and `postModelSwitch` (Claude Code v2.1.251), whose matcher is tested against the canonical name of the model the session switches to (e.g. `.*opus.*`); `preModelSwitch` can block the switch.\n\n> **Note:** Rulesync implements OpenCode hooks as a plugin at `.opencode/plugins/rulesync-hooks.js` and Kilo hooks as a plugin at `.kilo/plugins/rulesync-hooks.js`, so importing from OpenCode/Kilo to rulesync is not supported. Both only support command-type hooks (not prompt-type).\n\n> **MiMo Code note:** MiMo Code loads the same OpenCode plugin API from `.mimocode/plugins/` (global: `~/.config/mimocode/plugins/`), so Rulesync writes the same plugin to `.mimocode/plugins/rulesync-hooks.js` with the OpenCode event mapping. Entries under `mimocode.hooks` are emitted only for MiMo Code. Import is not supported, as for OpenCode.\n\n> **Note:** Rulesync implements Amp hooks as a generated TypeScript plugin at `.amp/plugins/rulesync-hooks.ts` (project) or `~/.config/amp/plugins/rulesync-hooks.ts` (global), so importing arbitrary Amp plugin code is not supported. Amp supports command hooks for `sessionStart` → `session.start`, `preToolUse` → `tool.call`, `postToolUse` → `tool.result`, `beforeSubmitPrompt` → `agent.start`, and `stop` → `agent.end`. Tool-event matchers are regular expressions against the Amp tool name; definitions with a matcher on any lifecycle event are skipped with a warning. A failing `preToolUse` command rejects the tool call and lets the agent continue; other mapped events observe the command result. Amp's `changes.prompt` event (fired while Amp prepares the prompt behind its Ship or Push to Branch button) has no canonical equivalent, so it is accepted only under the `amp.hooks` override block, keyed by its native name (for example `\"amp\": { \"hooks\": { \"changes.prompt\": [{ \"command\": \"./ship-notes.sh\" }] } }`): the trimmed stdout of each command that exits with code 0 is appended to that prompt (multiple outputs are separated by a blank line), a failing command contributes nothing and cannot block the prompt, and a `matcher` on it is skipped with a warning.\n\n> **Amp command syntax:** Amp executes plugin commands with [Bun Shell](https://bun.com/docs/runtime/shell), whose syntax differs slightly from POSIX shells. Use `$VAR` for environment expansion (`${VAR}` remains literal) and `$(command)` for command substitution (backticks remain literal). Rulesync passes the authored command through unchanged so quoting and escaped operators retain their Bun Shell meaning.\n\n> **Note:** GitHub Copilot's format uses separate `powershell` and `bash` fields for hooks, plus a portable `command` field that upstream copies into both when neither is present. Rulesync picks between them with the canonical `shell` selector, and writes the portable `command` field when a hook does not set one. Earlier versions chose the field from the platform Rulesync happened to run on; regenerate to get a machine-independent file. VS Code reads the same file and maps `bash` to its `osx` / `linux` and `powershell` to its `windows` overrides; its own per-OS keys `windows`, `linux` and `osx` (see the [VS Code hooks reference](https://code.visualstudio.com/docs/agents/reference/hooks-reference)) can be authored on a canonical hook and are passed through verbatim on generate and preserved on import, including for an entry that carries only those and no `command` — such an entry lands in the shared `hooks` block without a canonical `command`, so every other target would generate it command-less; the import warns, and adding a `command` fixes it. VS Code also documents that it ignores `matcher` values, so a hook with a `matcher` is dropped for this target rather than emitted unscoped.\n\n> **Note:** Hook file paths per tool:\n>\n> - **Copilot (cloud agent / VS Code)** — project: `<project>/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. Command hooks carry `bash`/`powershell` with optional `timeoutSec`, plus the canonical `env` map and a pass-through `cwd`. On import, `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. Which command field is written is chosen by the canonical `shell` selector; without it the portable `command` field is written, which upstream copies to both. It is deliberately **not** chosen from the platform Rulesync runs on: the cloud agent runs hooks in a **Linux sandbox** where only `bash` and `command` are honored, so a `powershell` entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to `bash` (with a warning) on every platform. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of \"every `*.json` is loaded\": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.\n> - **Copilot CLI** — project: `<project>/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global directory is the documented user-level hooks location, which loads every `*.json` inside it, so only the filename is Rulesync's choice. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves to `bash` (with a warning) on every platform, so importing the same file yields the same canonical config everywhere. The CLI-only shell-free form — `exec` naming an executable plus an `args` list, spawned without a shell — is the canonical `command` + `args` exec form: a canonical `args` list (an empty one counts) makes Rulesync write `exec`/`args` instead of a shell field, dropping any `shell` selector with a warning, and an imported `exec` entry lands as `command` + `args` (an empty list when upstream omitted `args`) rather than as a hook with no command; an `exec` combined with `bash`/`powershell`/`command`, which the hooks reference says not to do, imports as the `exec` with a warning), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. The CLI's `disableAllHooks` switch (disable every hook, repository- and user-level) lives in its settings file rather than in a hooks file, so it is authored as `copilotcli.disableAllHooks` in `.rulesync/hooks.jsonc` and written to `.github/copilot/settings.json` (project) / `~/.copilot/settings.json` (global). Only that key is patched, and only when it is authored, so a value set by hand survives an unrelated regeneration and other settings (`deniedUrls`, `model`, ...) are preserved; `false` is written as is, which at project scope overrides a user-level `true` because the repository value takes precedence. Import reads it back into the same override. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference) and the [CLI config dir reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference).\n> - **Antigravity IDE / Antigravity CLI** — project: `<project>/.agents/hooks.json`; global: `~/.gemini/config/hooks.json`. Both targets share the same dedicated `hooks.json` (a Claude-Code-style matcher map nested under a generated `rulesync` hook name), so enabling both writes the same file.\n> - **Devin Desktop (formerly Windsurf)** — project: `<project>/.windsurf/hooks.json`; global: `~/.codeium/windsurf/hooks.json`. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand.\n> - **Factory Droid** — project: `<project>/.factory/hooks.json`; global: `~/.factory/hooks.json`. A standalone `hooks.json` is keyed **directly by event name** (`{\"PreToolUse\": [...]}`); the `hooks` wrapper Droid documents belongs to `settings.json` only, so Rulesync writes the bare event map. The file is Rulesync-owned and rewritten wholesale, which also repairs a `hooks.json` an earlier version left in the wrapped shape — Droid found no known event key at the top level of that file, so none of those hooks ever fired; regenerate to fix. Import accepts both shapes: the top level when it names an event, and the `hooks` key otherwise, which is also how the legacy `.factory/settings.json` read-time fallback is understood. Import falls back in order: `.factory/hooks.json`, then the `.factory/settings.json` `hooks` key (with `.factory/settings.local.json` overlaid, since Droid reads the pair as one) when those settings actually carry a `hooks` key — an unrelated `settings.local.json` therefore does not shadow the next step — then the pre-1.0 `.factory/hooks/hooks.json` — last because Droid renames that file to `hooks.migrated.json` once it has migrated it, so a copy still sitting there is the least likely to be live. Only `.factory/hooks.json` is ever written. As with permissions below, a hook that came from the machine-local file is imported into `.rulesync/hooks.json` like any other, so the next `generate` writes it into the committed hooks files of every targeted tool — and a hook carries a command someone else's machine would then run. Drop a personal hook from `.rulesync/hooks.json` after importing if it should stay personal. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.\n> - **AugmentCode** — project: `<project>/.augment/settings.json`; global: `~/.augment/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file (which also holds `toolPermissions`).\n> - **IBM Bob** — project: `<project>/.bob/settings.json`; global: `~/.bob/settings/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file; the sibling keys Bob keeps there are left untouched.\n> - **Tabnine CLI** — project: `<project>/.tabnine/agent/settings.json`; global: `~/.tabnine/agent/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file; the sibling keys Tabnine keeps there (`mcpServers`, `tools`, `hooksConfig`, ...) are left untouched.\n> - **Snowflake Cortex Code** — project: `<project>/.cortex/settings.json`; global: `~/.snowflake/cortex/hooks.json`. Hooks are merged under the top-level `hooks` key of either file; the sibling keys Cortex Code keeps there are left untouched, and neither file is removed by `--delete`. The per-hook `enabled` flag and the Remote Hooks `source` object (`{ \"source\": \"github:org/repo/path\", \"ref\": \"main\" }`, command hooks only) round-trip as-is.\n> - **Codewhale** — project: `<project>/.codewhale/hooks.toml`; global: the `[hooks]` table of `~/.codewhale/config.toml`. The project file holds top-level `[[hooks]]` entries and is owned by Rulesync; in the global config only the `[[hooks.hooks]]` entry list is replaced, while the table's own settings (`enabled`, `default_timeout_secs`, `working_dir`) and every other Codewhale setting are left untouched, and the file is never deleted.\n> - **Continue** — project: `<project>/.continue/settings.json`; global: `~/.continue/settings.json`. Hooks are merged under the top-level `hooks` key of the Continue CLI settings file; the sibling keys Continue keeps there are left untouched, and the file is never removed by `--delete`.\n> - **CodeBuddy Code** — project: `<project>/.codebuddy/settings.json`; global: `~/.codebuddy/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys CodeBuddy keeps there are left untouched, and the file is never removed by `--delete`. The gitignored `.codebuddy/settings.local.json` layer is left to CodeBuddy.\n> - **Command Code** — project: `<project>/.commandcode/settings.json`; global: `~/.commandcode/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys Command Code keeps there are left untouched, and the file is never removed by `--delete`.\n> - **Letta Code** — project: `<project>/.letta/settings.json`; global: `~/.letta/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys Letta Code keeps there (including the `hooks.disabled` switch) are left untouched, and the file is never removed by `--delete`.\n> - **Qoder** — project: `<project>/.qoder/settings.json`; global: `~/.qoder/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions` and (in the user file) `mcpServers`; the sibling keys Qoder keeps there are left untouched, and the file is never removed by `--delete`.\n> - **GitLab Duo CLI** — project: `<project>/.gitlab/duo/hooks.json`; global: `~/.gitlab/duo/hooks.json`. The file holds only `{ \"hooks\": { ... } }`, so Rulesync owns and rewrites it as a whole (and `--delete` removes it). Hooks are an experiment in GitLab Duo CLI 8.95.0 and later, and project-level hooks are disabled by default: start the CLI with `--enable-project-hooks` or set `GITLAB_ENABLE_PROJECT_HOOKS=true` to run them. Only `sessionStart` ⇄ `SessionStart` is documented, so every other event is skipped with a warning. Only `command` hooks are emitted; `matcher` (a regex on the session source, `startup` or `resume`) and `timeout` (seconds) round-trip. The CLI exports `DUO_PROJECT_DIR` to hook processes, so a `./`-relative or dot-relative command is anchored to `\"$DUO_PROJECT_DIR\"/`. See the [hooks docs](https://docs.gitlab.com/user/gitlab_duo_cli/customize/#hooks).\n> - **Crush** — project: `<project>/crush.json` (or `.crush.json` when that file already exists); global: `~/.config/crush/crush.json`. Hooks are written as the flat `hooks.PreToolUse` list of Crush's shared JSON config; the sibling keys Crush keeps there (`providers`, `options`, `mcp`, `permissions`, ...) are left untouched, and the file is never removed by `--delete`.\n> - **Pool** — project: `<project>/.poolside/settings.yaml`; global: `~/.config/poolside/settings.yaml`. Hooks are written as the per-event lists of the `hooks` block of Pool's shared YAML settings file; the sibling keys Pool keeps there (`pool`, `mcp_servers`, `tools`, `paths`, ...) and the non-event keys of the `hooks` block itself (`stop_hook_max_continuations`) are left untouched, and the file is never removed by `--delete`.\n> - **Kimi Code** — global only: `~/.kimi-code/config.toml`. Hooks are merged into the shared `[[hooks]]` array without replacing unrelated model, provider, or permission settings.\n> - **Vibe Code** — project: `<project>/.vibe/hooks.toml`; global: `~/.vibe/hooks.toml`. Stable since v2.21.0, which removed the `enable_experimental_hooks` flag: declaring a hook is enough, so Rulesync writes nothing into `.vibe/config.toml` for hooks.\n\n> **Note:** Claude Code hooks are merged under the `hooks` key of the shared `.claude/settings.json` (project) / `~/.claude/settings.json` (global). Whichever feature writes that file (hooks, permissions or ignore), Rulesync adds a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as the first entry when the file does not have one yet, so editors validate and autocomplete the settings. A `$schema` the file already states is left untouched, whatever its value.\n\n> **Note:** Because each AI tool evolves its own hook surface at its own pace, the matrix above reflects the events Rulesync currently translates. When a tool ships a new event that Rulesync does not yet support, the most reliable path is to open an issue — the matrix is the intended baseline to compare against.\n\n> **Note:** Kiro has two hook formats, and the target you pick decides which one you get. The **`kiro-cli`** and **`kiro-ide`** targets write the standalone `{ \"version\": \"v1\", \"hooks\": [ … ] }` file that both products read today — `.kiro/hooks/rulesync.json` in project scope and `~/.kiro/hooks/rulesync.json` in user scope — with one array entry per hook carrying `name`, `trigger`, an optional `matcher`, an `action` (`{ \"type\": \"command\", \"command\": … }` for a canonical `command` hook, `{ \"type\": \"agent\", \"prompt\": … }` for a `prompt` hook), an optional `timeout` in seconds, `enabled`, and — on `Stop`-trigger command hooks — the optional `confirm` prompt (with its nested `confirmCommand`). Triggers are PascalCase (`sessionStart` ⇄ `SessionStart`, `stop` ⇄ `Stop`, …); triggers with no canonical event, such as `PostFileSave` or `PreTaskExec`, are reachable through the **shared `kiro.hooks` override block** and pass through verbatim. Both targets write the same filename, so they read that one block rather than per-target `kiro-cli.hooks` / `kiro-ide.hooks` blocks — otherwise a divergent override would make the file's content depend on which target was generated last. Generating either or both targets therefore always yields the same file. A block authored under `kiro-cli.hooks` or `kiro-ide.hooks` is read by nothing and reported with a warning; move it to `kiro.hooks` (the same key the deprecated `kiro` alias, and the Kiro MCP and permissions wiring, already use). A second filename would not help either, since Kiro runs every `*.json` in the directory and both products read the same one. The deprecated `kiro` alias reads the same block but writes a different format, so the two writers each keep to their own vocabulary: a standalone-only trigger (`PostFileSave`, `PreTaskExec`, …) in `kiro.hooks` is dropped from the alias's `.kiro/agents/default.json` output with a warning, and an agent-config spelling (`agentSpawn`, `fileEdited`, …) is translated to its v1 equivalent (`SessionStart`, `PostFileSave`) for the standalone targets rather than written as a trigger Kiro does not define. Keys neither writer recognizes still pass through unchanged.\n>\n> The deprecated **`kiro`** alias still writes the older embedded format: `.kiro/agents/default.json` under the `hooks` field, merged with any existing agent configuration (tools, allowedTools, etc.). There, both `sessionEnd` and `stop` map to Kiro's `stop` event, only `command`-type hooks are supported (`prompt`-type hooks are silently skipped), per-hook timeouts are `timeout_ms` (milliseconds), and `cache_ttl_seconds` maps to the canonical `cacheTtl` field in both directions. Kiro's [hooks migration guide](https://kiro.dev/docs/cli/v3/hooks-migration/) states this format \"does not work in 3.0\", so prefer `kiro-cli`.\n>\n> If you generated `kiro-cli` hooks with an earlier Rulesync version, the `hooks` block it left in `.kiro/agents/default.json` is not removed for you — that file is shared with the permissions and subagents features, so Rulesync never deletes it. On Kiro CLI 2.x, which reads both formats, leaving it in place means every hook fires twice; delete the block by hand (or run Kiro's own agent migration) after regenerating. For the same reason, `rulesync import --targets kiro-cli --features hooks` now reads only the standalone file: to pull hooks out of an existing agent config, import with `--targets kiro`. Two event-surface differences come with the switch as well: Kiro's standalone triggers have no `SessionEnd`, so a canonical `sessionEnd` hook is dropped with a warning — use `stop` instead — and `cacheTtl` has no counterpart outside the agent-config format.\n\n> **Note:** Antigravity (IDE and CLI) writes a dedicated `hooks.json` keyed by a **named hook** whose value holds the event map, e.g. `{ \"rulesync\": { \"PreToolUse\": [ { \"matcher\": \"...\", \"hooks\": [...] } ], \"Stop\": [ { \"hooks\": [...] } ] } }`. Rulesync emits a single generated hook under the stable name `rulesync`. It supports five lifecycle events — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `preModelInvocation` ⇄ `PreInvocation`, `postModelInvocation` ⇄ `PostInvocation`, and `stop` ⇄ `Stop` — where `PreInvocation`/`PostInvocation`/`Stop` are matcher-less handler lists. On import, both the named-hook wrapper and a legacy flat top-level event map are accepted, and the optional per-hook `enabled` flag is ignored.\n\n> **Note:** Devin Desktop (formerly Windsurf) Cascade Hooks (GA) are written to a dedicated `hooks.json` whose top-level `hooks` key maps each Cascade event name to a **flat array** of hook objects (no `matcher`, no `type`, no inner `hooks` wrapper, and no `timeout`). Each object carries `command` and/or `powershell`, plus optional `show_output` and `working_directory`. Rulesync splits the generic tool lifecycle into Devin's file/command/MCP-specific events, so the canonical events map bijectively: `beforeReadFile` ⇄ `pre_read_code`, `beforeTabFileRead` ⇄ `post_read_code`, `afterTabFileEdit` ⇄ `pre_write_code`, `afterFileEdit` ⇄ `post_write_code`, `beforeShellExecution` ⇄ `pre_run_command`, `afterShellExecution` ⇄ `post_run_command`, `beforeMCPExecution` ⇄ `pre_mcp_tool_use`, `afterMCPExecution` ⇄ `post_mcp_tool_use`, `beforeSubmitPrompt` ⇄ `pre_user_prompt`, `afterAgentResponse` ⇄ `post_cascade_response`, `beforeAgentResponse` ⇄ `post_cascade_response_with_transcript`, and `worktreeCreate` ⇄ `post_setup_worktree`. Canonical events with no Devin equivalent (e.g. `sessionStart`, `stop`) are dropped with a logged warning. The Cascade Hooks file location (`.windsurf/hooks.json` / `~/.codeium/windsurf/hooks.json`) is retained from the Windsurf era and is unaffected by the rebrand.\n\n> **Note:** AugmentCode (Auggie CLI) hooks are merged under the top-level `hooks` key of the shared `.augment/settings.json` (project) / `~/.augment/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). The `hooks` block is merged in place so it coexists with the `toolPermissions` block from the permissions feature. Auggie [documents](https://docs.augmentcode.com/cli/config) that its settings files accept JSON with Comments (comments and trailing commas), so Rulesync reads `settings.json` / `settings.local.json` as JSONC for every feature that shares the file (`mcp`, `hooks`, `permissions`) and edits only the keys it owns, leaving hand-written comments in place; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `notification` ⇄ `Notification`, and `beforeSubmitPrompt` ⇄ `PromptSubmit` (added in Auggie 0.27.0). The `matcher` field (a case-sensitive regex, default `.*`, with `mcp:*` support) applies only to the tool events `PreToolUse`/`PostToolUse`; any matcher on the session events (including `Notification` and `PromptSubmit`) is dropped with a logged warning. Two Auggie-specific fields round-trip as well: a command hook's `args` (extra argv the runner appends, authored as `args` on the canonical hook) and the matcher group's `metadata` (`includeConversationData` / `includeMCPMetadata` / `includeUserContext`, which select what the runner puts in the JSON payload the script receives). `metadata` belongs to the group upstream, so it is authored on any hook of the group and re-applied to every hook of that group on import. Both matter because the `hooks` key is owned in the shared settings file: a value not written here is erased from a hand-written `settings.json` on the next generate. Commands are emitted verbatim — Auggie exposes `AUGMENT_PROJECT_DIR` as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. Only `command`-type hooks are supported. Auggie expresses `timeout` in **milliseconds** (default `60000`), so the canonical seconds value is multiplied by 1000 on generate and divided on import. On **import** (project scope), Rulesync also reads the layered overrides file `<workspace>/.augment/settings.local.json` — a gitignored, machine-specific file that Auggie merges on top of `settings.json` — and combines it over the base settings before importing, following Auggie's documented layering (simple values take the local override, `mcpServers`/`plugins` replace wholesale, and other objects/lists — including the `hooks` events — are combined across tiers), so personal hook overrides are picked up without dropping base events. This overlay is **import-only and project-only**: Rulesync never writes `settings.local.json`, AugmentCode documents no global `~/.augment/settings.local.json`, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes the same hooks format into a plugin bundle's `hooks/hooks.json` (project scope only) as a `{ \"hooks\": { ... } }` document that Rulesync owns as a whole, reading the `augmentcode` override section. Because plugin hook scripts ship inside the plugin, `./`-relative commands are anchored to `$AUGMENT_PLUGIN_ROOT` (the braced `${AUGMENT_PLUGIN_ROOT}` in the exec form) instead of being emitted verbatim. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Note:** IBM Bob hooks are merged under the top-level `hooks` key of the shared `.bob/settings.json` (project) / `~/.bob/settings/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Bob keeps in that file survive a regenerate; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact` (the two compaction events are Bob Shell 2.0.3+; the IDE ignores them), and `stop` ⇄ `Stop`. The `matcher` field applies only to the tool events `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a logged warning. Bob compiles the matcher as a regex over the tool name and treats an omitted matcher as match-all, so the canonical catch-all `\"*\"` (not a valid regex) is emitted as _no_ matcher instead of verbatim. Commands are emitted verbatim (no directory prefix is added). `command` and `http` hooks are supported: Bob Shell spells the webhook handler `https` (`{ \"type\": \"https\", \"url\": \"...\", \"timeout\": <seconds> }`), so a canonical `http` hook is written with `type: \"https\"` and read back as `http` on import; `prompt`, `agent` and `mcp_tool` hooks are skipped. Bob-only entries can be authored under the `bob.hooks` override key. See the [Bob Shell lifecycle hooks docs](https://bob.ibm.com/docs/shell/configuration/lifecycle-hooks) and the [Bob IDE lifecycle hooks docs](https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks).\n\n> **Note:** Tabnine CLI hooks are merged under the top-level `hooks` key of the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global), as per-event arrays of matcher groups (`{ \"EventName\": [ { \"matcher\": \"...\", \"sequential\": true, \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"name\": \"...\", \"description\": \"...\", \"timeout\": 60000, \"env\": { ... } } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Tabnine keeps in that file survive a regenerate — including the sibling `hooksConfig` block (`enabled`, `disabled`, `notifications`), which Rulesync never manages; a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `BeforeAgent`, `stop` ⇄ `AfterAgent`, `preModelInvocation` ⇄ `BeforeModel`, `postModelInvocation` ⇄ `AfterModel`, `beforeToolSelection` ⇄ `BeforeToolSelection`, `preToolUse` ⇄ `BeforeTool`, `postToolUse` ⇄ `AfterTool`, `preCompact` ⇄ `PreCompress`, and `notification` ⇄ `Notification`. Tabnine accepts a `matcher` on every event: a regex over the tool name on `BeforeTool`/`AfterTool`, an exact string on the other events (for example the `Notification` kind), with an omitted matcher or `.*` meaning match-all — so the canonical catch-all `\"*\"` is emitted as _no_ matcher. Tabnine expresses `timeout` in **milliseconds** (default `60000`), so the canonical seconds value is multiplied by 1000 on generate and divided on import; `name`, `description` and `sequential` (which makes the hooks of one matcher group run in order) round-trip through the canonical entry, and `env` is written only when every entry is a safe `KEY=VALUE` pair (an unsafe entry is dropped with a warning). Commands are emitted verbatim — Tabnine expands `$TABNINE_PROJECT_DIR` at runtime, so keep it in the command rather than a resolved path — and only `command`-type hooks are supported; a hook with no `command` is skipped with a warning because Tabnine would discard it. Tabnine-only entries can be authored under the `tabnine.hooks` override key. See the [hooks docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/hooks) and the [hook configuration reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/hooks/configuration).\n\n> **Note:** Snowflake Cortex Code hooks are merged under the top-level `hooks` key of `.cortex/settings.json` (project) / `~/.snowflake/cortex/hooks.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 60, \"enabled\": true } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Cortex Code keeps in the project file survive a regenerate; a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStop` ⇄ `SubagentStop`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `permissionRequest` ⇄ `PermissionRequest`, `preCompact` ⇄ `PreCompact`, `notification` ⇄ `Notification`, and `setup` ⇄ `Setup`. Both `command` and `prompt` hook types are written (`http` hooks are skipped with a warning). Cortex Code compiles `matcher` as a case-sensitive regex over the runtime tool name and documents `*` as \"all tools\", so the canonical catch-all is emitted verbatim; as in Claude Code, a matcher on `UserPromptSubmit` or `Stop` is dropped with a logged warning. `timeout` is in seconds (default 60) and the per-hook `enabled` flag round-trips. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CORTEX_PROJECT_DIR\"/…` (Cortex Code exports that variable to hook scripts) and the prefix is stripped again on import; other commands are emitted verbatim. Cortex-Code-only entries can be authored under the `cortexcode.hooks` override key. See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#hooks).\n\n> **Note:** Codewhale hooks are `{ event, command }` entries with snake_case event names: `sessionStart` → `session_start`, `sessionEnd` → `session_end`, `stop` → `turn_end`, `beforeSubmitPrompt` → `message_submit`, `preToolUse` → `tool_call_before`, `postToolUse` → `tool_call_after`, `afterError` → `on_error`, `subagentStart` → `subagent_spawn`, `subagentStop` → `subagent_complete`, and `stopFailure` → `session_error`. Only `command` hooks are emitted; Codewhale runs them through the shell. On the three tool events (`preToolUse`, `postToolUse`, `afterError`) a matcher becomes a `tool_name` condition (a `|`-separated list becomes an `any` condition, and `*` globs are kept); a matcher that is a regular expression cannot be expressed and the hook is skipped with a warning rather than widened to every tool. Matchers are compared against Codewhale's own tool names (for example `exec_shell`, `write_file`, `edit_file`), which Rulesync does not translate. `timeout` becomes `timeout_secs` (a positive whole number of seconds; note that a global `default_timeout_secs` overrides every per-hook value), and `name`, `background`, `continue_on_error` and a raw Codewhale `condition` object pass through. Codewhale-only events (`mode_change`, `shell_env`, `session_idle`, `waiting_for_user`, `session_busy`) can be written by their native name under `codewhale.hooks`, where import also files them, and are emitted verbatim. Codewhale runs a project `.codewhale/hooks.toml` only in a trusted workspace after its exact bytes are approved with `/hooks approve`, so every regeneration that changes the file needs approving again.\n\n> **Note:** Continue hooks are merged under the top-level `hooks` key of `.continue/settings.json` (project) / `~/.continue/settings.json` (global), the settings file of the Continue CLI (`cn`), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 60 } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Continue keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Seventeen events are supported, all under their Claude Code names — `sessionStart`, `sessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop`, `subagentStart`, `subagentStop`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `preCompact`, `notification`, `configChange`, `teammateIdle`, `taskCompleted`, `worktreeCreate` and `worktreeRemove`. Continue accepts `command`, `prompt`, `http` and `agent` hooks (the `mcp_tool` and `function` types are skipped with a warning); a `prompt`/`agent` hook carries its `model`, and the `statusMessage`, `once` and `async` (command hooks only) fields pass through. As in Claude Code, a matcher on an event that takes none (`UserPromptSubmit`, `Stop`, `TeammateIdle`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`) is dropped with a logged warning. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CONTINUE_PROJECT_DIR\"/…` (Continue exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. An `http` hook's `headers` are written as authored into the committable `.continue/settings.json` — Continue expands `$VAR` / `${VAR}` references in hook headers for the variables named in the hook's `allowedEnvVars`, so write `Authorization: Bearer $TOKEN` with `allowedEnvVars: [\"TOKEN\"]` rather than a literal secret. Continue-only entries can be authored under the `continue.hooks` override key. See the [Continue CLI hooks source](https://github.com/continuedev/continue/tree/5522c6f44ca0ac3528b37244818fbfa39b5af470/extensions/cli/src/hooks).\n\n> **Note:** CodeBuddy Code hooks are merged under the top-level `hooks` key of `.codebuddy/settings.json` (project) / `~/.codebuddy/settings.json` (global), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 30 } ] } ] }`). Only the `hooks` key is rewritten, so the `permissions` block and the other settings CodeBuddy keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. The supported events are the lifecycle events CodeBuddy's plugins reference lists for user-defined and plugin hooks alike, under Claude Code's PascalCase names (`beforeSubmitPrompt` ⇄ `UserPromptSubmit`, the others by name: `SessionStart`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `InstructionsLoaded`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, `ElicitationResult`, `SessionEnd`); other canonical events are skipped with a warning. `command`, `http` (`url`, `headers`), `prompt` and `agent` hooks are emitted (`mcp_tool` is skipped with a warning), and a prompt hook's `continueOnBlock` flag is carried both ways. CodeBuddy documents prompt hooks for `Stop`, `UserPromptSubmit` and `PreToolUse` only; rulesync does not filter prompt hooks per event, so keep them on those events. `matcher` is passed through verbatim; `UserPromptSubmit`, `Stop`, `SubagentStop` and `PostCompact` (and the plugin-only events that fire on every occurrence) take none, so a matcher there is dropped with a logged warning. `timeout` is in seconds. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CODEBUDDY_PROJECT_DIR\"/…` and the prefix is stripped again on import; other commands are emitted verbatim. CodeBuddy-only entries can be authored under the `codebuddy.hooks` override key. See the [hooks docs](https://www.codebuddy.ai/docs/cli/hooks) and the [plugins reference](https://www.codebuddy.ai/docs/cli/plugins-reference).\n\n> **Note:** Command Code hooks are merged under the top-level `hooks` key of `.commandcode/settings.json` (project) / `~/.commandcode/settings.json` (global), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 30 } ] } ] }`). Only the `hooks` key is rewritten, so the `permissions` block and the other settings Command Code keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Four events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `stop` ⇄ `Stop` and `sessionStart` ⇄ `SessionStart`. Only `command` hooks exist (`prompt`, `http` and the other types are skipped with a warning). `matcher` is a regex over the tool name on the two tool events, passed through verbatim; Command Code tests it case-insensitively against its display names (`SHELL`, `READ`, `EDIT`, `WRITE`, `SEARCH` for grep, `GLOB`, `LIST`, `WEB_FETCH`, `WEB_SEARCH`, ...; `*` matches every tool), so a Claude-Code-style `Bash` matcher never fires there — write Command-Code-specific matchers under the `commandcode.hooks` override key. `Stop` and `SessionStart` take none, so a matcher there is dropped with a logged warning. `timeout` is in seconds. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$COMMANDCODE_PROJECT_DIR\"/…` (Command Code exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. Command-Code-only entries can be authored under the `commandcode.hooks` override key. See the [hooks docs](https://commandcode.ai/docs/hooks).\n\n> **Note:** Letta Code hooks are merged under the top-level `hooks` key of `.letta/settings.json` (project) / `~/.letta/settings.json` (global), in Claude Code's per-event matcher-array shape. The event keys of `hooks` are rewritten as a whole, so existing Letta Code `prompt` hooks there are replaced (with a warning); the `hooks.disabled` switch, the `permissions` block and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `notification`, `stop`, `subagentStop`, `preCompact`, and `beforeSubmitPrompt` ⇄ `UserPromptSubmit`. Only `command` hooks are emitted (Letta Code also runs `prompt` hooks, but only on some events, so `prompt` and the other types are skipped with a warning). `matcher` is kept on the four tool events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`) and dropped with a warning elsewhere. `timeout` is written in milliseconds (the canonical value is in seconds) and converted back on import. Letta Code runs hooks with the project directory as their working directory (also exported as `LETTA_WORKING_DIR`), so commands are written verbatim. Letta-Code-only entries can be authored under the `lettacode.hooks` override key. Letta Code's hooks page is not published yet, so this surface follows the [source](https://github.com/letta-ai/letta-code/tree/main/src/hooks).\n\n> **Note:** Qoder hooks are merged under the top-level `hooks` key of `.qoder/settings.json` (project) / `~/.qoder/settings.json` (global), in Claude Code's per-event matcher-array shape; the per-machine `.qoder/settings.local.json` is left to you. The event keys of `hooks` are rewritten as a whole, while the `permissions` block, the `mcpServers` map and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. All 27 documented Qoder events are supported, under Claude Code's PascalCase names (`beforeSubmitPrompt` ⇄ `UserPromptSubmit`). The `command`, `http`, `prompt` and `agent` handler types are emitted (`mcp_tool` is skipped with a warning), with `timeout` in seconds, `model` on `prompt` / `agent` hooks, `url` / `headers` / `allowedEnvVars` on `http` hooks, `if`, `statusMessage` and `once` on any hook, and `shell`, `env`, `args`, `async` and `asyncRewake` on `command` hooks. `matcher` is dropped with a warning on `UserPromptSubmit`, `Stop`, `CwdChanged`, `WorktreeCreate`, `WorktreeRemove`, `TaskCreated`, `TaskCompleted` and `TeammateIdle`, and kept elsewhere. Dot-relative commands (`./x.sh`, `.rulesync/hooks/x.sh`) are anchored with `\"$QODER_PROJECT_DIR\"`, which Qoder exports into every hook process; other commands are written verbatim. Qoder-only entries can be authored under the `qoder.hooks` override key. See the [hooks docs](https://docs.qoder.com/en/cli/hooks).\n> **Note:** Crush hooks are written under the top-level `hooks` key of Crush's shared JSON config — `<project>/crush.json` (project) / `~/.config/crush/crush.json` (global, via `--global`) — as a **flat per-event array** of hook objects with no matcher-group wrapper: `{ \"hooks\": { \"PreToolUse\": [ { \"name\": \"...\", \"matcher\": \"...\", \"command\": \"...\", \"timeout\": 30 } ] } }`. `PreToolUse` is the only event Crush fires (the only one its hooks docs list), so `preToolUse` ⇄ `PreToolUse` is the whole mapping and every other canonical event is skipped with a warning; only `command` hooks are supported. `matcher` is a Go regular expression matched case-sensitively against the lower-case tool name (`bash`, `view`, `edit`, `write`, `mcp_<server>_<tool>`, ...), so it should be spelled in lower case; a canonical `*` or empty matcher is omitted so the hook applies to every tool. `timeout` is an integer number of seconds (Crush defaults to 30), so a fractional canonical value is rounded up. The hook receives a Claude-Code-style JSON payload on stdin (`event`, `tool_name`, `tool_input`, `session_id`, `cwd`) and answers through the JSON output envelope on stdout; exit code 2 blocks the call and exit code 49 halts the turn. At project scope Crush reads both `.crush.json` and `crush.json` and merges them (objects recursively, lists concatenated, `.crush.json` on top), so neither file hides the other. When a `.crush.json` already exists it is the file written (otherwise `crush.json`); if the `crush.json` next to it still carries the keys rulesync owns — for example from a generate run before `.crush.json` was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only `~/.config/crush/crush.json` is read. Only the `hooks` key is rewritten, so the `providers`, `options`, `mcp` and `permissions` keys Crush keeps in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. Crush's newer Bash-based `crushrc` compiles its `hook add` builtin into the same `hooks.PreToolUse` list and overrides the JSON key by key, so a `crushrc` next to the generated file takes precedence. Import reads `hooks.PreToolUse` back as `preToolUse` (Crush accepts the key in any case and with underscores, so `pre_tool_use` folds into the same event); any other event key in the file is filed under the `crush.hooks` override so it round-trips verbatim. See the [hooks docs](https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) hooks are written under the top-level `hooks` key of Pool's shared YAML settings file — `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) / `~/.config/poolside/settings.yaml` (global, via `--global`) — as a **flat per-event list** of hook objects with no matcher-group wrapper: `hooks: { PreToolUse: [ { name: ..., matcher: ..., command: ..., timeout: 60 } ] }`. Six canonical events map onto Pool's: `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preCompact` ⇄ `PreCompact`, `sessionStart` ⇄ `SessionStart` and `stop` ⇄ `Stop`; every other canonical event is skipped with a warning, and only `command` hooks are supported (a `command` hook without a command is skipped with a warning). Pool's schema requires `matcher` on every entry but only tests it on `PreToolUse` and `PostToolUse`, against the tool name (`\"\"` or `*` matches any tool, a bare name is an exact match, `a|b` a list, anything else a regular expression), so the canonical `matcher` is written on those two events (`*` when the hook has none) and a matcher authored on any other event is replaced by the `*` placeholder with a warning, since Pool would ignore it. `timeout` is an integer number of seconds (Pool defaults to 60), so a fractional canonical value is rounded up. Pool runs the hooks of an event sequentially, feeds each a JSON envelope on stdin (`hook_api_version`, `hook_event_name`, `session_id`, `cwd`, plus the event's fields such as `tool_name` and `tool_input`) and reads a decision JSON from stdout; exit code 2 blocks the action with the stderr text as the reason. Pool merges its settings levels user → project → `settings.local.yaml` → `--settings`, where a hook re-declared under the same `name` at a more specific level replaces the earlier one in place and unnamed hooks accumulate, so name a hook when a project should be able to override it. Only the event lists of the `hooks` block are rewritten: `stop_hook_max_continuations` (the cap Pool puts on `Stop` continuations) and the sibling keys Pool keeps in the same file (`pool`, `mcp_servers`, `tools`, `paths`, `sandbox`, ...) survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. Import reads the six events back into their canonical names — a `*` or empty matcher is dropped, as is any matcher on an event Pool never tests it on — and files any other event key under the `pool.hooks` override so it round-trips verbatim. See the [hooks docs](https://docs.poolside.ai/hooks) and the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **Note:** Vibe Code (mistral-vibe) hooks are written to a dedicated `.vibe/hooks.toml` (project) / `~/.vibe/hooks.toml` (global) as a flat `[[hooks]]` TOML array. Each entry carries its own event `type`, a `command`, and optional `name`, `timeout` (seconds, default 60), and `description`. Tool-hook entries (`pre_tool` / `post_tool`) additionally carry a tool-name `match` (an fnmatch glob like `bash`/`mcp_*` or a `re:`-prefixed regex, case-insensitive — the canonical `matcher` field; `*` means \"any tool\") and an optional `strict` flag; `post_agent` carries neither. Three events are supported — `preToolUse` ⇄ `pre_tool`, `postToolUse` ⇄ `post_tool`, and `stop` ⇄ `post_agent` (fires after every assistant turn that ends without pending tool calls). Only `command`-type hooks are emitted. The `command` runs through a shell, so pipes, `&&`, redirects and `$VAR` work. Vibe v2.25.1–v2.25.4 briefly skipped any hook whose `command` contained a backslash; v2.25.5 removed that skip, so Rulesync writes such commands verbatim without a warning. Rulesync cannot detect the installed Vibe version, so if your hook commands contain backslashes and you are on v2.25.1–v2.25.4, upgrade to v2.25.5 or later. Vibe v2.21.0 graduated hooks from experimental: it renamed all three types (`before_tool` → `pre_tool`, `after_tool` → `post_tool`, `post_agent_turn` → `post_agent`) and removed the `enable_experimental_hooks` flag, so declaring a hook is enough and Rulesync no longer writes an auxiliary `.vibe/config.toml`. `HookType` is a strict enum upstream, so an entry using an old name is rejected outright. Import still reads the three old spellings, so a `hooks.toml` left behind from before the rename is repaired into the current names rather than being lost. The `vibe-plugin` target writes the same format into a plugin bundle's `ai.mistral.vibe/hooks.toml` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Cline note (hooks):** Cline's file-based hooks are executables, not a config file: it resolves one script per lifecycle event from `<project>/.clinerules/hooks/` (project) or `~/Documents/Cline/Hooks/` (global, via `--global`), named exactly after the event — the extensionless name on Unix, `<Event>.ps1` on Windows. Rulesync emits a wrapper script per configured event in **both** spellings (the POSIX one with mode `0755`, since Cline spawns the file itself), plus a `rulesync-hooks.json` manifest listing the scripts it owns. The wrapper feeds the event payload it receives on stdin to each configured command in order and answers on stdout with `{\"cancel\": …, \"contextModification\": \"\", \"errorMessage\": …}`: a command exiting `2` cancels the task, any other non-zero exit is surfaced through `errorMessage` without cancelling. Ten canonical events map onto Cline's fixed script names — `sessionStart` → `TaskStart`, `sessionEnd` → `SessionShutdown`, `beforeSubmitPrompt` → `UserPromptSubmit`, `preToolUse` → `PreToolUse`, `postToolUse` → `PostToolUse`, `preCompact` → `PreCompact`, `notification` → `Notification`, `taskCompleted` → `TaskComplete`, `afterError` → `TaskError`, `stopCancelled` → `TaskCancel`. That set is the union of the two runtimes reading the same directory: the VS Code extension's `VALID_HOOK_TYPES` and the SDK/CLI's `HookConfigFileName`, which drops `Notification` but adds `TaskError` and `SessionShutdown`. A script named for an event the running runtime does not know is simply never spawned. That applies to unknown _names_ only: for an event it does know, the SDK/CLI runtime spawns **both** spellings, because it lists hook files per path rather than per event. Each generated script therefore opens with a guard that stands down on the platform the other one owns — the `.ps1` is a no-op off Windows, and the extensionless script is a no-op on Windows. Both are needed: off Windows the runtime runs the `.ps1` through `pwsh`, and on Windows it infers the extensionless file's interpreter from its `#!/bin/bash` shebang and normalizes it to a bare `bash`, so with Git Bash on `PATH` the two spellings **both execute your commands** — a genuine double fire. (The Unix side was noise rather than duplication: the `.ps1` body shells out through `cmd /c`, which Unix does not have, so it failed on every fire instead.) The PowerShell guard tests that `$IsWindows` is both defined and false, since it does not exist at all in the Windows PowerShell 5.1 that `powershell -File` starts; the POSIX guard matches `$OSTYPE`/`uname` against the `msys`/`cygwin`/`mingw` family. `TaskCancel` fires **instead of** `TaskComplete` when a task is aborted (the VS Code extension runs it from `afterRun` when the result status is `aborted`, the SDK/CLI as `agent_abort`), so it is the same shape as canonical `stopCancelled` on Grok CLI and Codex CLI; it is observation-only and cannot cancel anything. Cline's `TaskResume` has no canonical counterpart and is left unmapped; only `command`-type hooks are supported, and `matcher` is ignored because the wrapper is a plain shell script with no payload parser. Each command is passed to `bash -c` as a single quoted argument, so its own quotes and operators cannot break the wrapper; a command that is not valid shell syntax is reported through `errorMessage` instead of cancelling (an unparseable command would otherwise exit `2`). Note that the same command string runs under `bash` on Unix and `cmd /c` on Windows, so shell-specific syntax is not portable across the two generated spellings. Three caveats on ownership: the hooks directory is also where you hand-author your own hooks and the filenames are fixed by Cline, so every generated script carries a `rulesync-owned: cline-hooks` marker line and a script **without** that marker is never overwritten (that event is then not managed by Rulesync, and generate warns about it); a script whose event you remove is rewritten as a no-op rather than deleted, while dropping the `cline` target with `--delete` removes the marked scripts outright; and `rulesync gitignore` lists the generated script names explicitly rather than the whole directory, so a hand-authored hook sharing one of those names needs a negation in your own `.gitignore` if you want to commit it. Generated scripts cannot be imported back into canonical hooks, so this target is generate-only. Cline's in-process hook surface (`AgentHooks` from `@cline/core`) is a separate mechanism that Rulesync does not target. See [`VALID_HOOK_TYPES`](https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts) and [`HookConfigFileName`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/hooks/hook-file-config.ts) in the Cline source.\n\n> **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `<project>/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ \"hooks\": { \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\" } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum (it has no `SubagentStart`/`SubagentStop`; its twelfth value, `PreToolUseResult` added in v1.48.0, has no canonical counterpart yet and is not emitted). The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. A canonical `failClosed: true` on a `preToolUse` command hook is written as Goose's `on_failure: \"block\"` (v1.48.0+), which makes a hook that crashes, times out or returns unparseable output deny the tool call instead of allowing it; `false` is omitted as Goose's default, and on import `on_failure: \"block\"` / `\"allow\"` read back as `failClosed: true` / `false`. Goose only reads `on_failure` on `PreToolUse`, so a `failClosed: true` on any other event is dropped with a warning rather than emitted. One exception applies to the matcher: Goose compiles it with `Regex::new` and **silently drops the whole rule** when compilation fails, and the canonical catch-all `\"*\"` is not a valid regex, so it is emitted as _no_ matcher (which Goose treats as match-all) instead of verbatim.\n\n> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"sequential\": false, \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-two lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10), and `sessionDelete` ⇄ `SessionDelete` (fires after an explicitly selected session is deleted, via the interactive `/delete` command or the ACP `deleteSession` request; matcher-less, added in Qwen Code v0.21.3). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt` (which carries the required `prompt` body — with `$ARGUMENTS` interpolation — and an optional `model` override, both round-tripped; a prompt hook without a `prompt` is warned about at generate time since Qwen Code loads it and fails it at runtime), `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The canonical `timeout` (seconds) is converted for command hooks: since [Qwen Code v0.23.4](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.4) ([PR #11615](https://github.com/QwenLM/qwen-code/pull/11615)) a command hook `timeout` is read in seconds, but a value of `1000` or more keeps its legacy millisecond meaning, so a canonical `timeout` below `1000` is written as is and one of `1000` or more is written in milliseconds (`1800` becomes `1800000`). On import, a command hook `timeout` of `1000` or more is read as milliseconds and divided by 1000 (`10000` becomes `10`). http and prompt hook timeouts are always seconds upstream and pass through unchanged, as do `function` hook timeouts (milliseconds upstream, SDK-registered). Qwen Code also honors a numeric-string `timeout` such as `\"60000\"`, so import reads one as the number it spells (before the millisecond rule above); a string that is not a number is dropped, the way Qwen Code falls back to the hook's default for it, and the rest of its matcher group is still imported. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).\n\n> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ \"EventName\": [ { \"match\": \"...\", \"command\": \"...\", \"description\": \"...\", \"timeout\": ..., \"cwd\": \"...\", \"env\": { ... } } ] }`. All thirteen of Reasonix's events (the `Events` list in `internal/hook/hook.go`) are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `permissionRequest` ⇄ `PermissionRequest`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, `postModelInvocation` ⇄ `PostLLMCall`, `notification` ⇄ `Notification`, and `preCompact` ⇄ `PreCompact`. `match` (Reasonix's matcher field name) is honored only on the tool-scoped events `PreToolUse`/`PostToolUse`/`PostToolUseFailure`/`PermissionRequest`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Each entry also carries the canonical `env` map and a pass-through `cwd` (the hook's working directory; Reasonix defaults to the session's), the same two fields Copilot's hooks take, so both are written on generate and lifted back on import instead of being wiped when rulesync rewrites the entry. Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md).\n\n> **Note:** Grok CLI (xAI Grok Build) hooks are written to a dedicated, standalone `rulesync.json` that Grok auto-discovers from `.grok/hooks/*.json` (project) / `~/.grok/hooks/*.json` (global). The JSON shape is Claude-Code-compatible: each event nests under the top-level `hooks` key as a per-matcher array (`{ \"hooks\": { \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] } }`). All fifteen documented events map 1:1 onto canonical arms — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `permissionDenied` ⇄ `PermissionDenied`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `stopCancelled` ⇄ `StopCancelled`, `notification` ⇄ `Notification`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, and `postCompact` ⇄ `PostCompact`. `StopCancelled` runs **instead of** `Stop` when a turn ends without completing — a user interrupt, a declined permission prompt, the `--max-turns` limit, or a no-progress bail-out — so a `stop` hook alone does not cover interrupted turns; it is observation-only and cannot block. A `matcher` (a regex) is honored on every event except `Stop` and `UserPromptSubmit`, which always fire; a matcher on either of those two is dropped with a warning. What the regex tests depends on the event: the tool name on `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `PermissionDenied`, the notification type on `Notification` (e.g. `idle_prompt`), the subagent type on `SubagentStart` / `SubagentStop` (e.g. `explore`), the start source on `SessionStart`, the end reason on `SessionEnd`, the compaction trigger (`manual` or `auto`) on `PreCompact` / `PostCompact`, the error type on `StopFailure` (`rate_limit`, `authentication_failed`, …), and the cancellation reason on `StopCancelled` (`user_interrupt`, `permission_rejected`, `permission_cancelled`, `max_turns`, `no_progress`, or `unknown`). Earlier Rulesync versions inferred a much narrower set from Claude Code compatibility and dropped the other matchers, so a hook authored that way fired on everything; regenerate to get them back. Commands are emitted verbatim (Grok documents no project-directory variable). See the [Grok hooks docs](https://docs.x.ai/build/features/hooks). Both handler types Grok defines round-trip: a `command` hook runs a command, and an `http` hook POSTs the payload to its `url`. A command hook's `env` map (upstream `HookConfig.env`, merged into the spawned command's `extra_env`) round-trips as well. Note that a `.rulesync/hooks.*` obtained with `rulesync fetch` can therefore point a Grok hook at any URL — read it before generating.\n\n> **Note:** Kimi Code hooks are global-only and written as flat `[[hooks]]` entries in `~/.kimi-code/config.toml`, with `event`, `command`, and optional `matcher`/`timeout`. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `stop`, `stopFailure`, `notification`, `subagentStart`, `subagentStop`, `preCompact`, and `postCompact`. Kimi's native `PermissionResult`, `Interrupt`, `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` events are not mapped from canonical events for Kimi (`Interrupt` is the same shape as canonical `stopCancelled`, which Grok CLI, Codex CLI and Cline map, but it stays native-only on Kimi for now), but they can be written and preserved through the `kimi-code.hooks` override under their native names. (`TaskStarted` is deliberately not folded into the canonical `taskCreated`: it fires when a background task starts and matches on task kind, while `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated` fired during task creation.) Only `command` hooks are emitted. A matcher is dropped with a warning on `Stop`, `SessionHeartbeat`, and `Interrupt`, the three events whose Event Reference row documents the matcher as an empty string. Kimi treats `matcher` as a regular expression tested against the event target, so on these events it is tested against `\"\"` and any non-trivial matcher never matches — such a hook silently never ran. Dropping the matcher is therefore a behavior change for existing configs: the hook now fires, which is what authoring it meant. Every other event matches a real value (`UserPromptSubmit` the submitted prompt text, `PermissionRequest` and `PermissionResult` the tool name, `PreCompact` the trigger, and so on), so matchers there are kept. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as `npm test`. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source `.rulesync/hooks.jsonc`; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. This also means a hook that inspects the session project through `$PWD` or `git rev-parse` sees the source directory instead; such a hook can read the session project from the `cwd` field Kimi passes on stdin, or you can set `\"kimi-code\": { \"runFromSourceDirectory\": false }` in `.rulesync/hooks.jsonc` to emit every command verbatim so it runs in the session project directory, as Claude Code and Codex CLI global hooks do. Opting out restores the exposure described above, so use absolute script paths (e.g. `$HOME/hooks/guard.py`) rather than relative ones. Import does not recover the flag: a re-imported config regenerates with the wrapper unless you set it again. Kimi requires `timeout` to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the [Kimi Code hooks docs](https://moonshotai.github.io/kimi-code/en/customization/hooks.html).\n> **Note:** Codex CLI hooks are written to `.codex/hooks.json` (project) / `~/.codex/hooks.json` (global) with PascalCase event names. The canonical `stopCancelled` event maps to Codex's `Interrupt` (shipped in rust-v0.150.0), which runs when the user interrupts an active turn on the main thread; it never fires for idle threads or subagents, Codex ignores any configured `matcher` on it, its output cannot prevent the interruption (only an optional `systemMessage` JSON is honoured), and command timeouts are capped at 3s (1s by default). Rulesync forwards the hook as authored, so keep `timeout` an integer between 1 and 3 and do not rely on a matcher. See the [Codex hooks docs](https://learn.chatgpt.com/docs/hooks.md).\n\n## `.github/mcp.json` and `.copilot/mcp-config.json`\n\nExample:\n\n```json\n{\n \"mcpServers\": {\n \"serena\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"--from\", \"git+https://github.com/oraios/serena\", \"serena\", \"start-mcp-server\"]\n },\n \"github\": {\n \"type\": \"http\",\n \"url\": \"http://localhost:3000/mcp\"\n },\n \"local-dev\": {\n \"type\": \"local\",\n \"command\": \"node\",\n \"args\": [\"scripts/start-local-mcp.js\"]\n }\n }\n}\n```\n\nThis file is used by the GitHub Copilot CLI for MCP server configuration. Rulesync manages it by converting from the unified `.rulesync/mcp.jsonc` format. Both scopes use the same `{ \"mcpServers\": {...} }` shape but write to different paths:\n\n- **Project mode:** `.github/mcp.json` (relative to project root) — the Copilot CLI auto-loads MCP servers from this workspace config file ([changelog v1.0.61, 2026-06-09](https://github.com/github/copilot-cli)).\n- **Global mode:** `~/.copilot/mcp-config.json` (relative to home directory) — the personal/global MCP configuration.\n\n> **Migration note:** earlier Rulesync versions wrote the **project-mode** Copilot CLI MCP config to `.copilot/mcp-config.json` (the same path used for global mode). Project mode now writes the dedicated workspace file `.github/mcp.json` instead, so a previously generated project-scope `.copilot/mcp-config.json` is no longer managed and can be removed by hand.\n\nRulesync preserves explicit `type` values for `http`, `sse`, and `local` servers. For command-based servers that omit a transport type, Rulesync emits the mandatory `\"type\": \"stdio\"` field required by the Copilot CLI. `streamable-http` is written as `http`, the transport it names, and the canonical `httpUrl` alias is normalized to the `url` Copilot CLI reads. A server the Copilot CLI config cannot express is skipped with a warning rather than failing the run: one that declares no transport at all (the shape a Kilo `{\"enabled\": …}` toggle imports as, which switches off a server some other config layer defines — every entry here defines a server), one that names a remote transport but no `url`/`httpUrl`, one that names a local transport but no `command`, and a `ws` server, since Copilot CLI has no WebSocket transport.\n\nThe canonical per-server `enabledTools` is written as Copilot CLI's own [`tools` allowlist](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers) — `[\"*\"]` (the default) exposes every tool, a list exposes only those names — and imports back as `enabledTools`. A server that already carries a native `tools` value keeps it, and a colliding `enabledTools` is dropped with a warning. `disabledTools` has no counterpart upstream (expressing it would need the server's full tool list), so it is not emitted.\n\n## `rulesync/commands/*.md`\n\nExample:\n\n```md\n---\ndescription: \"Review a pull request\" # command description\ntargets: [\"*\"] # * = all, or specific tools\ncopilot: # copilot specific parameters (optional)\n description: \"Review a pull request\"\n agent: \"agent\" # (optional) VS Code prompt-file agent: \"ask\", \"agent\", \"plan\", or a custom agent name (replaces the deprecated \"mode\")\nantigravity: # antigravity specific parameters\n trigger: \"/review\" # Slash command name; the command is emitted as .agents/skills/review/SKILL.md\ntakt: # takt specific parameters (optional; emitted under .takt/facets/instructions/)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\npi: # pi coding agent specific parameters (optional)\n argument-hint: \"[message]\" # Hint shown in Pi's command palette\ncodexcli: # Codex CLI custom-prompt specific parameters (optional)\n argument-hint: \"[message]\" # Hint shown for the custom prompt's arguments\nroo: # Roo Code specific parameters (optional)\n mode: \"architect\" # (optional) mode slug to switch to before running the command body (e.g. \"code\", \"architect\")\n---\n\ntarget_pr = $ARGUMENTS\n\nIf target_pr is not provided, use the PR of the current branch.\n\nExecute the following in parallel:\n\n...\n```\n\nThe command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping.\n\n> **Antigravity migration note:** Antigravity [retires workflows on October 19, 2026](https://antigravity.google/docs/migration/workflows-to-skills) in favor of skills, so `antigravity-ide` and `antigravity-cli` commands are emitted as skills (`.agents/skills/<name>/SKILL.md`, invoked as `/<name>`) instead of `.agents/workflows/<name>.md`. The workflow-only `turbo` option and the `# Workflow:` header are no longer written. Legacy workflows are still imported by `rulesync import`. Running `rulesync generate --delete` removes every `*.md` file left in the workflow directories, including hand-written workflows, so import those first if you want to keep them. If a rulesync skill with the same name as a command is written to the same skills directory, the skill wins and the command is skipped with a warning, even in a run that does not generate that skill. The skill of a deleted command is removed by `rulesync generate --delete` with the skills feature enabled, because the commands feature does not sweep the shared skills tree.\n\n> **Codex CLI deprecation note:** Codex CLI's own docs now state \"Custom prompts are deprecated. Use skills for reusable instructions\" (see [Custom Prompts](https://developers.openai.com/codex/custom-prompts)). Rulesync's `codexcli` commands still generate the global-only `~/.codex/prompts/*.md` custom-prompt files described above — they remain functional and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `codexcli` skills support (see `.rulesync/skills/*/SKILL.md` below) instead.\n\n> **GitHub Copilot prompt-files note:** the `copilot` target writes commands as VS Code prompt files (`.github/prompts/*.prompt.md`). VS Code's own docs now state that prompt files \"are deprecated for Agent Host sessions and aren't loaded by Agent Host. They continue to work with the Local agent for now, but the Local agent will be removed in a future release\" (see [Prompt files](https://code.visualstudio.com/docs/agent-customization/prompt-files)), and VS Code ships a default-on migration that turns them into agent skills. Rulesync's `copilot` commands still generate the prompt files described above — they remain functional on the Local agent and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `copilot` skills support (see `.rulesync/skills/*/SKILL.md` below), which is not affected by this deprecation.\n\n> **Warp note:** Warp documents skills as its custom slash-command surface — any skill is invocable as `/{skill-name}` with `$ARGUMENTS` / `$ARGUMENTS[N]` / `$N` argument substitution — so rulesync emits each command onto the native skills surface as `.warp/skills/<name>/SKILL.md` (project) / `~/.warp/skills/<name>/SKILL.md` (global, via `--global`), with `name`/`description` frontmatter derived from the command file. Warp's `.warp/workflows/` YAML files are parameterized shell-command templates, not agent prompts, and are deliberately not used. Commands import and `--delete` are no-ops for `warp` because the skills feature owns the `.warp/skills/` tree (importing it as commands would double-import every skill) — mirrors the Devin note below. Keep command and skill names distinct for this target, since a command and a skill sharing a name write the same `SKILL.md` path. See the [Warp skills docs](https://docs.warp.dev/agent-platform/capabilities/skills/).\n\n> **Devin note:** Devin's extensibility docs no longer document a standalone workflows/commands component — reusable prompts invoked as slash commands are [Skills](https://docs.devin.ai/cli/extensibility/skills/overview) (`/name`). Rulesync therefore emits each command onto the native skills surface as `.devin/skills/<name>/SKILL.md` (project) / `~/.config/devin/skills/<name>/SKILL.md` (global, via `--global`), with `name`/`description` frontmatter derived from the command file. The legacy Windsurf/Cascade-era `.devin/workflows/` and `~/.codeium/windsurf/global_workflows/` locations are no longer emitted (stale outputs there stay gitignored but are not cleaned up automatically). Commands import and `--delete` are no-ops for `devin` because the skills feature owns the `.devin/skills/` tree (importing it as commands would double-import every skill). Note that a command and a skill sharing the same name write the same `SKILL.md` path, so keep command and skill names distinct for this target. The `devin-plugin` target writes skills into a plugin bundle's `skills/<name>/SKILL.md` in the same format (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Agent Skills standard note (`agentsskills`):** Rulesync accepts the legacy rulesync spellings on input but always **emits** the shapes the [specification](https://agentskills.io/specification) requires: `allowed-tools` becomes a space-separated scalar (a YAML list is joined), `compatibility` becomes a string (an object is flattened to `key: value` pairs), and `metadata` values are stringified so the block stays a string→string map. Generation also checks the normative constraints and warns — without failing the run — when `name` is empty, longer than 64 characters, contains anything but lowercase letters, digits and single hyphens, or does not match its parent directory name; when `description` is empty or longer than 1024 characters; when `compatibility` exceeds 500 characters; or when an `allowed-tools` list entry contains whitespace, which the space-separated form cannot represent. These are warnings rather than errors because a conformant client only _skips_ such a skill, and because import stays lenient as the [client-implementation guide](https://agentskills.io/client-implementation/adding-skills-support) advises. **Import** reports the same empty-`description` violation, for the one reason it matters on that side too: a conformant client would skip the skill, so a user who is never told has no reason to fix it. Rulesync converts rather than loads, so the skill is imported all the same — dropping the directory would lose content that is still repairable. `hermesagent` reads the same shape and reports it identically. A value that normalizes to the empty string (`compatibility: {}`, `allowed-tools: []`) is dropped rather than written, since the spec requires `compatibility` to be 1–500 characters when present. On **import**, `allowed-tools` is normalized back to the canonical rulesync list, so a generate → import round trip leaves `.rulesync/skills/**` in the shape it started in (the `compatibility` and `metadata` coercions are one-way, because the legacy object/number forms have no conformant equivalent). `hermesagent` reads the same `agentsskills` block and applies the same normalization in both directions, so one rulesync source never produces two different on-disk spellings — except for `metadata`, which stays structured there because Hermes reads `metadata.hermes.*` as YAML. A `hermesagent:` override still wins over the shared block (as for every tool-specific section), so a list or mapping written there is emitted as-is and reported as a spec violation rather than rewritten. Validate the result with the spec's own [`skills-ref validate`](https://github.com/agentskills/agentskills/tree/main/skills-ref). Import leniency is root-based as well as tool-based: any tool scanning an Agent Skills interop root (project `.agents/skills/`, global `~/.agents/skills/`, or Amp's `~/.config/agents/skills/`) skips-and-warns on a skill (directory-form or flat-file) that fails to load there — the cross-vendor directory is where foreign-authored, potentially non-conformant skills live — while each tool's own native root (e.g. Rovo Dev's `.rovodev/skills/`) stays fail-fast.\n\n> **Malformed frontmatter note:** a `SKILL.md` whose YAML fails to parse is retried once with its top-level unquoted values quoted, which recovers the case the Agent Skills [client-implementation guide](https://agentskills.io/client-implementation/adding-skills-support) names — `description: Use this skill when: the user asks about PDFs`, where the second colon ends the scalar. The retry rewrites only top-level `key: value` lines whose value contains a colon followed by whitespace, so a URL (`homepage: https://example.com`) and anything already quoted, a flow collection or a block scalar are left alone. An inline comment is cut off before the value is examined, so `allowed-tools: Read # TODO: add Bash later` is neither rewritten nor turned into a value that grants what the comment had disabled; a file the retry cannot fix still fails with the error it actually has. Recovery is reported with a warning: fix the file itself, since other tools read it with their own parsers.\n\n> **Replit note:** Replit's skills page states conformance to the [Agent Skills specification](https://agentskills.io/specification), so `replit.allowed-tools` accepts either the spec's space-separated string or a canonical rulesync list and is always **emitted** as the string; `replit.compatibility` likewise accepts the spec's string alongside the legacy object form. On import, `allowed-tools` is normalized back to the list, mirroring `deepagents` — so keep list entries free of whitespace, since the space-separated form cannot represent an entry such as `Bash(git commit:*)` and a client would read it back as two. An object `compatibility` is emitted unchanged rather than flattened: unlike the join, that conversion would be one-way, so the legacy form stays as-is and is simply not spec-conformant on disk.\n\n> **Junie skills note:** Junie treats `description` as **optional** in a skill's `SKILL.md` — \"If `description` is not provided in the frontmatter, Junie CLI extracts the first paragraph of the body content as the description.\" Rulesync's canonical frontmatter requires one, so on **import** a missing `description` is filled in the same way, from the first body paragraph (wrapped lines joined into a single line, since it becomes a YAML scalar); a `SKILL.md` Junie loads fine therefore no longer aborts the import. **Markdown headings are skipped**, matching upstream's \"If the body is also empty or contains only headings, the skill will fail to load\" — so a body opening with `# Skill Name` yields the prose beneath it, not the title. That matters beyond the import: an imported description is written back out explicitly on the next generate, so importing the title would replace Junie's own correct fallback with it, for every tool. A fenced code block is **not** special-cased — it is ordinary content, so a body whose first non-heading paragraph is a fence yields the fence text, which is a good reason to author a `description` explicitly. When nothing remains (an empty or headings-only body), Junie could not load the skill either, so Rulesync skips **that one skill** with a warning and keeps importing the rest. Generation always writes an explicit `description`, which Junie's own docs recommend. Junie also loads skills from the shared Agent Skills root — `<projectRoot>/.agents/skills/` and `~/.agents/skills/` — so Rulesync registers it as an import fallback at either scope; it is import-only (the `agentsskills` target owns writing there) and is never removed by Junie-target orphan deletion. When the same skill name exists in both roots, the Junie-specific `.junie/skills/` root takes precedence — Junie's own docs are silent on that ordering, so this matches how Rulesync already resolves the shared root for `kimi-code`. Names are compared case-insensitively there too, so `.agents/skills/My-Skill` does not shadow `.junie/skills/my-skill`; see the cross-root duplicate note under [`.rulesync/skills/*/SKILL.md`](#rulesyncskillsskillmd). Junie's third skill source — the custom folders added with `--skill-location` or the `skill-locations` key of its `config.json` — is out of scope for the `junie` target: Rulesync neither reads nor writes those paths, so a skill kept only there stays invisible to `rulesync import`. See the [agent skills docs](https://junie.jetbrains.com/docs/agent-skills.html).\n\n> **Vibe skills note:** Vibe discovers skills under `.vibe/skills/` (project) and `~/.vibe/skills/` (global), plus the shared `.agents/skills/` root at **both** scopes — Vibe's `user_skills_dirs` returns `~/.vibe/skills` and `~/.agents/skills` alike. Rulesync registers the shared root as an import fallback at either scope; it is import-only and is never removed by Vibe-target orphan deletion. When the same skill name exists in both roots the Vibe-specific root takes precedence, compared case-insensitively; see the cross-root duplicate note under [`.rulesync/skills/*/SKILL.md`](#rulesyncskillsskillmd). The names `vibe` and `skill-creator` are reserved for Vibe's built-in skills: its `SkillManager` skips a project or user skill by either name with only a debug log (legacy backend, still the default; the unified harness loads the built-ins from the shipped `vibe` plugin under namespaced names and does not reserve them), so Rulesync warns at generate time when a Vibe-targeted skill uses one of them (the skill is still written; rename it to make it available in Vibe). **`disable-model-invocation` requires Vibe v2.25.5 or later.** Since v2.25.5 Vibe honors `disable-model-invocation: true` in `SKILL.md`, making the skill explicit-only (the user can still invoke it with a slash command, but the model cannot load it on its own); Rulesync writes the key from the `vibe:` block, falling back to the root-level `disable-model-invocation`, and round-trips it into the `vibe:` block on import. Rulesync emits the key regardless of the installed Vibe version, and older releases ignore it, so on those versions the model can still load the skill on its own — upgrade to v2.25.5 or later to rely on it. The `vibe-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Pi skills note:** Pi implements the [Agent Skills specification](https://agentskills.io/specification), so `pi.allowed-tools` accepts either the spec's space-delimited string or a canonical rulesync list and is always **emitted** as the string; `pi.compatibility` likewise accepts the spec's string alongside the legacy object form. Importing a spec-conformant `SKILL.md` used to fail outright. On import, `allowed-tools` is normalized back to the list, mirroring `deepagents`; keep list entries free of whitespace, since the space-delimited form cannot represent an entry such as `Bash(git commit:*)`. An `allowed-tools` value that normalizes to the empty string (an empty list) is dropped rather than written. An object `compatibility` is emitted unchanged rather than flattened, because that conversion would be one-way.\n\n> **Hermes Agent note:** Commands are global-only and remain distinct from skills. Rulesync writes JSON command specs to `~/.hermes/rulesync/commands/<name>.json`, installs the `rulesync-commands` plugin under `~/.hermes/plugins/`, and enables it in `~/.hermes/config.yaml`. The plugin registers each spec with Hermes's [`ctx.register_command()` plugin API](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins/) and dispatches the prompt through `delegate_task`; invocation arguments are appended to the prompt. `.rulesync/skills/<name>/SKILL.md` still generates a full [Hermes Agent Skill](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills/) under `~/.hermes/skills/<name>/SKILL.md`, which Hermes also exposes as a dynamic slash command. Rulesync rejects command/skill names that would collide in Hermes's slash-command namespace, and rejects nested command paths that flatten to the same name. Generate commands with `rulesync generate --targets hermesagent --features commands --global`.\n>\n> Releases before this native plugin transport emitted Hermes commands as `~/.hermes/skills/<name>/SKILL.md`. Rulesync cannot distinguish those files from real user-authored skills safely, so remove an obsolete legacy file manually after confirming that `.rulesync/skills/<name>/SKILL.md` does not own it.\n\n> **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block followed by the prompt body; besides `description`, Qwen Code's command loader reads `when_to_use` (invocation guidance), `argument-hint` (completion hint), and `disable-model-invocation`, all typed and round-tripped. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block.\n\n> **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`variant`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. Entries under the OpenCode V2 spelling `commands` are read too, since OpenCode V1 also loads them (from v1.18.24): an entry without a `template` is skipped, a `provider/model#variant` model (or a `{ providerID, model, variant }` object) is split into `model` and `variant`, and a `command` entry wins over a `commands` entry of the same name, as in OpenCode. A `subagent` in a `commands` entry is read as `subtask` like everywhere else, although OpenCode V1 itself ignores it there. OpenCode V2 renamed `subtask` to `subagent` (keeping `subtask` as a deprecated alias), while OpenCode V1 accepts only `subtask`, so rulesync reads `subagent` as `subtask` — in Markdown files, inline entries, and the `opencode` (or MiMo Code's `mimocode`) key of `.rulesync/commands/*.md` alike, with `subagent` winning when both are set, as in V2 — and always writes `subtask`, which both versions understand.\n\n> **Kilo Code note:** Custom commands are Markdown files under `.kilo/commands/` (project) / `~/.config/kilo/commands/` (global, via `--global`), invoked as `/<name>` where the name is the file stem. Kilo reserves the `/goal` name for its session-goal command and **throws** when a custom command by that name is found in either directory (`The /goal command is reserved for session goals. Rename the custom command.`), which aborts its whole command scan rather than only the one file. Rulesync therefore skips a rulesync command whose emitted stem is `goal` for the `kilo` target with a warning, at both scopes, and generates the rest; rename the command or exclude it from `kilo` via `targets`. The check runs on the name as written — with the default basename flattening `nested/goal.md` is also skipped, while with the `flattenedCommandNaming: \"path\"` config option it becomes `nested-goal.md` and is written. Import is unaffected: a `goal.md` already in `.kilo/commands/` is read as it is. See [`packages/opencode/src/command/index.ts`](https://github.com/Kilo-Org/kilocode/blob/v7.6.2/packages/opencode/src/command/index.ts).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) reads the OpenCode command format from `.mimocode/commands/*.md` (project) / `~/.config/mimocode/commands/*.md` (global, via `--global`). Its tool-specific frontmatter goes under a `mimocode` key, which takes the same fields as the `opencode` key; on import, inline `command` entries in `.mimocode/mimocode.json(c)` are read as for OpenCode, except the V2 `commands` spelling, which MiMo Code does not load.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads Pi-style Markdown commands from `.omp/commands/*.md` (project) / `~/.omp/agent/commands/*.md` (global, via `--global`), not from Pi's `prompts/` directory. The format is Pi's (`description` and `argument-hint`, with `$1` / `$@` / `$ARGUMENTS` placeholders); tool-specific frontmatter goes under an `omp` key, which takes the same fields as the `pi` key.\n\n> **GitLab Duo CLI note:** [Custom slash commands](https://docs.gitlab.com/user/gitlab_duo_cli/customize/#custom-slash-commands) are Markdown files under the cross-tool `.agents/commands/` (project, the same directory the `agentsmd` target writes) and `~/.gitlab/duo/commands/` (global, via `--global`), invoked as `/<name>` where the name is the file stem. Only the optional `description` frontmatter field is written; GitLab documents no argument placeholders, so the body is passed through as authored. Subdirectories are not supported, and the CLI discovers commands when it starts.\n\n> **AugmentCode note:** Commands are written to `.augment/commands/<name>.md` (project) / `~/.augment/commands/<name>.md` (global, via `--global`). Subdirectories are namespaces — `.augment/commands/git/commit.md` is `/git:commit` — so nested rulesync commands keep their nesting rather than being flattened to a basename. If you generated AugmentCode commands with an earlier Rulesync, the flattened files it wrote are still on disk under their old names; `--delete` removes them. Auggie also discovers commands under the cross-tool `.agents/commands/` root, so **import** reads that root too and treats a command found there as if it lived under `.augment/commands/` — the command's name is its path under whichever root it came from. Generation stays on `.augment/commands/`, and `.agents/commands/` is never written to or swept for orphans, since the files there may belong to another tool — Rulesync itself writes that root for the `agentsmd` target, so a command already imported from `.augment/commands/` is not imported again from there under a flattened name. Auggie's other shared root, `.claude/commands/`, is deliberately not read: it is Claude Code's own output, which Rulesync already imports as that target. Importing from a shared root is announced, because the result is a Rulesync command written for every target on the next generate. See the [custom commands docs](https://docs.augmentcode.com/cli/custom-commands). The `augmentcode-plugin` target writes the same format into a plugin bundle's `commands/` directory (project scope only). Auggie reads only `description` and `model` from a plugin command, so every other field in the `augmentcode` section (such as `argument-hint`) is dropped with a warning; import reads only the bundle's own `commands/`, never `.agents/commands/`. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands).\n\n> **Grok CLI note:** Custom slash commands are Markdown files under `.grok/commands/` (project) / `~/.grok/commands/` (global, via `--global`), read by the same Claude-Code-compatible frontmatter parser Grok uses for skills. Rulesync emits `description` plus, from the `grokcli:` block, `argument-hint`, `user-invocable` (default true) and `disable-model-invocation` (default false) — the same invocation-control pair Grok skills honor. Two upstream constraints are worth knowing. Grok's command scan is **flat and non-recursive**, so subdirectory namespacing is not supported: a nested `git/commit.md` is flattened onto `commit.md`, and two nested commands with the same basename collide (rulesync warns and the last one wins). And Grok collects skills before commands, letting **skills win name collisions** — a `.grok/skills/<name>/` shadows `.grok/commands/<name>.md`, so avoid giving a rulesync skill and a rulesync command the same name when targeting Grok. Any extra frontmatter keys are preserved on round-trip under the `grokcli:` block. See the [skills, plugins and marketplaces docs](https://docs.x.ai/build/features/skills-plugins-marketplaces).\n\n> **Rovo Dev CLI note:** Rovo Dev's \"saved prompts\" are a file-based custom-command surface made of a `prompts.yml` manifest plus per-prompt Markdown content files, invoked via `/prompts [title] [extra]`. Rulesync writes the content (no frontmatter) to `.rovodev/prompts/<name>.md` (project) / `~/.rovodev/prompts/<name>.md` (global, via `--global`), and rebuilds the sibling `.rovodev/prompts.yml` / `~/.rovodev/prompts.yml` manifest with one `{ name, description, content_file }` entry per prompt, `content_file` pointing at `prompts/<name>.md` (resolved relative to `prompts.yml`, matching Rovo Dev's own resolution order). The `prompts` array is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacing `mcpServers`); any other top-level key in an existing manifest is preserved, and the manifest is never deleted. See the [saved prompts](https://support.atlassian.com/rovo/docs/save-and-reuse-a-prompt-in-rovo-dev-cli/) and [CLI commands](https://support.atlassian.com/rovo/docs/rovo-dev-cli-commands/) docs.\n\n> **ZCode note:** Custom commands are Markdown files under `.zcode/commands/` (project) / `~/.zcode/commands/` (global, via `--global`), invoked from the input box with `/`. Only the global path is spelled out in ZCode's docs, which describe the workspace scope as \"workspace-level commands live in the project directory\"; the `.zcode/commands/` project path is rulesync's inference from that sentence and from the layout ZCode uses for its other workspace assets, not a documented path. The file name is the command's identifier, and the frontmatter carries the `description` shown in the command picker plus an `argument-hint`. ZCode's command scan is flat, so subdirectory namespacing is not modeled. Any extra frontmatter fields are preserved on round-trip under the `zcode:` block. See the [ZCode commands docs](https://zcode.z.ai/en/docs/commands). The `zcode-plugin` target writes the same format into a plugin bundle's `commands/` directory (project scope only), since ZCode parses plugin commands like `.zcode/commands/` ones. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Kimi Code plugin note:** The `kimi-code-plugin` target writes commands into a plugin bundle's `commands/` directory (project scope only), keeping subdirectories. Kimi registers each one as `/<plugin>:<name>`, taking the name from the relative path unless frontmatter `name` overrides it, and substitutes `$ARGUMENTS` in the body. Rulesync writes `description` plus any keys of an optional `kimi-code-plugin` section (such as `name`) as frontmatter, and import moves every key other than `description` back into that section. See [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **CodeBuddy Code note:** Custom slash commands are Markdown files under `.codebuddy/commands/` (project) / `~/.codebuddy/commands/` (global, via `--global`). Subdirectory namespacing is supported: `.codebuddy/commands/frontend/component.md` becomes the `/frontend:component` command. Rulesync emits the canonical `description` (a same-named key in the `codebuddy:` block is ignored); the other documented keys (`argument-hint`, `model`, `allowed-tools`, `disable-model-invocation`) and any extra ones round-trip through the `codebuddy:` block. See the [CodeBuddy Code slash commands docs](https://www.codebuddy.ai/docs/cli/slash-commands).\n\n> **Codewhale note:** User commands are Markdown files under `.codewhale/commands/` (project) / `~/.codewhale/commands/` (global, via `--global`). Codewhale loads the project directory only once the workspace is trusted, scans each directory flat (subdirectories are not supported), and names the command after the lowercased file stem unless frontmatter `name` replaces it. A project command cannot take the name of a protected built-in such as `/trust`, `/undo`, `/config` or `/mcp`; Codewhale skips it (or ignores such an alias) with a load error, and Rulesync warns when a generated project command would hit one, or when a frontmatter `name` is not a single slash-command token (Codewhale then falls back to the file stem). `description` is written from the canonical field, and the other keys of the `codewhale` section (`name`, `usage`, `arguments`, `argument-hint`, `allowed-tools`, `pausable`, `alias` / `aliases`, `hidden`) are written beside it. Codewhale reads the frontmatter line by line as `key: value` pairs rather than as YAML (no YAML quoting or escapes), so Rulesync writes and reads that format directly: every value is written verbatim on one line, a list of strings is joined with `, ` (the separator Codewhale splits `allowed-tools` and `aliases` on), and a nested value or a key other than letters, digits, `-` and `_` is dropped with a warning. On import every value comes back as a string, as Codewhale sees it. `$ARGUMENTS` and `$1`, `$2`, ... are passed through unchanged.\n\n> **IBM Bob note:** Custom slash commands are Markdown files under `.bob/commands/` (project) / `~/.bob/commands/` (global, via `--global`), one `<name>.md` per command, invoked as `/<name>`. The optional YAML frontmatter carries `description` and `argument-hint`; unknown keys are preserved through the `bob` frontmatter block on import. Bob's docs recommend grouping related commands in subdirectories, so nested paths under `.rulesync/commands/` are kept as-is (like Roo). See the [IDE slash commands docs](https://bob.ibm.com/docs/ide/features/slash-commands) and the [Bob Shell slash commands docs](https://bob.ibm.com/docs/shell/features/slash-commands).\n\n> **Tabnine CLI note:** Custom slash commands are **TOML** files under `.tabnine/agent/commands/` (project) / `~/.tabnine/agent/commands/` (global, via `--global`), one `<name>.toml` per command with a `description` string and the `prompt` body, invoked as `/<name>`. Subdirectory namespacing is supported (`.tabnine/agent/commands/git/commit.toml` becomes `/git:commit`), so nested paths under `.rulesync/commands/` are kept as-is. Tabnine CLI documents the Gemini CLI placeholders, so the universal `$ARGUMENTS` is translated to `{{args}}` and `` !`cmd` `` to `!{cmd}` (and back on import — see [Command Syntax](./command-syntax.md)); Tabnine's `@{path}` file-injection form has no universal counterpart and passes through untouched in both directions. Extra TOML keys round-trip through the `tabnine` frontmatter block: an import puts them there and a generate writes them back after `description` and `prompt`. See the [custom commands docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/commands).\n\n> **Continue note:** Custom slash commands are Continue **prompt files** — Markdown under `.continue/prompts/` (project) / `~/.continue/prompts/` (global, via `--global`), one `<name>.md` per command, invoked as `/<name>` once the frontmatter declares `invokable: true`. Rulesync always writes `name` (the file stem), `description` and `invokable: true`, so a file that lacked the flag by hand becomes a slash command after a generate. Placeholders are passed through untouched. Extra frontmatter keys round-trip through the `continue` frontmatter block; an import drops `invokable` (re-emitted on generate) and keeps `name` there only when it differs from the file stem. See the [prompts docs](https://docs.continue.dev/customize/deep-dives/prompts).\n\n> **Command Code note:** Custom slash commands are Markdown files under `.commandcode/commands/` (project) / `~/.commandcode/commands/` (global, via `--global`), one `<name>.md` per command invoked as `/<name>`; subdirectories are allowed and only group the files, the command name stays the file's basename. Command Code runs the file's whole trimmed body as the prompt — YAML frontmatter is not stripped, it is only skipped when the slash menu picks a summary line — so Rulesync writes the bare body without frontmatter; on import a hand-written file's frontmatter block (which Command Code would send as part of the prompt) is dropped and only the body becomes the rulesync command. See the [custom slash commands docs](https://commandcode.ai/docs/custom-slash-commands).\n\n> **Qoder note:** Custom commands are Markdown files with YAML frontmatter under `.qoder/commands/` (project) / `~/.qoder/commands/` (global, via `--global`). The command's invocation name comes from its path, and subdirectories namespace it with `:` (`.rulesync/commands/git/commit.md` becomes `/git:commit`), so nested rulesync commands keep their subdirectories. Rulesync writes the rulesync `description`, which Qoder requires (a command without one is written without it), and the fields of the `qoder` frontmatter block on top (for example `name`, which Qoder only uses as the display label in its command list); on import every key other than `description` goes back into the `qoder` block. See the [commands docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/command).\n\n> **JetBrains AI Assistant note:** the `aiassistant` target does not generate commands. The plugin can read custom chat commands from `.aiassistant/commands/`, but that support is switched off by default behind the registry key `llm.chat.input.language.user.commands.enabled` (default `false` in the plugin's `plugin.xml` as of build 263.5701.47), and the [AI Assistant documentation](https://www.jetbrains.com/help/ai-assistant/) does not describe it, so generated files would be ignored by a default installation. Rulesync will add commands support to the `aiassistant` target once JetBrains enables or documents the directory.\n\n> **Cline workflows note:** Cline reads a workflow's YAML frontmatter: `name` (the slash-command name) and a `disabled` / `enabled` switch ([`user-instruction-config-loader.ts`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/config/user-instruction-config-loader.ts)). The `cline:` section of `.rulesync/commands/*.md` is written as that frontmatter, every frontmatter key of an imported workflow is lifted back into `cline:`, and a command without a `cline:` section is still written as a bare body. `name` must be a string and `disabled` / `enabled` booleans — Cline rejects the whole workflow otherwise — so a value of another type is dropped with a warning. The workflow toggle in Cline's UI is stored in Cline's own state, not in the file, so it is not round-tripped.\n\n## `rulesync/subagents/*.md`\n\nNested sources such as `.rulesync/subagents/review/security.md` retain their\ndirectory structure only for targets that support nested agents (Claude Code and\nKimi Code). Other targets are skipped with a warning; a flat agent with the same\nname can be authored separately for them. Before running `generate --delete`,\nimport any hand-written nested Claude Code agents you want to keep: nested\n`.claude/agents/**` files are now included in the orphan sweep, at both project\nand global scope.\n\n**Upgrade note:** earlier versions only read Markdown files directly under\n`.rulesync/subagents/` and ignored its subdirectories. Since\n[#3187](https://github.com/dyoshikawa/rulesync/pull/3187), every\n`.rulesync/subagents/**/*.md` is loaded, so an existing file such as\n`_drafts/old.md` starts generating for its targets, and Markdown without valid\nsubagent frontmatter (for example `docs/README.md`) is skipped with a warning on\nevery run. Move such files out of `.rulesync/subagents/` after upgrading. For\nClaude Code and Kimi Code, which identify agents by frontmatter `name`, two\nsources that declare the same `name` are both generated, with a warning that the tool may load only one of them.\n\nExample:\n\n```md\n---\nname: planner # subagent name\ntargets: [\"*\"] # * = all, or specific tools\ndescription: >- # subagent description\n This is the general-purpose planner. The user asks the agent to plan to\n suggest a specification, implement a new feature, refactor the codebase, or\n fix a bug. This agent can be called by the user explicitly only.\nclaudecode: # for claudecode-specific parameters\n model: inherit # opus, sonnet, haiku, fable, a full model id, or inherit (default)\n tools: [\"Read\", \"Write\"] # (optional) allowed tools (string or list)\n disallowedTools: [\"Bash\"] # (optional) tools to remove (string or list)\n permissionMode: default # (optional) default | acceptEdits | bypassPermissions | plan\n maxTurns: 20 # (optional) maximum agentic turns\n skills: [\"skill-creator\"] # (optional) Agent Skills to utilize (string or list)\n color: cyan # (optional) UI color (e.g. red, blue, green, cyan, ...)\n memory: project # (optional) user | project | local (`rulesync gitignore` ignores the `local` tree, `.claude/agent-memory-local/`)\n effort: high # (optional) low | medium | high | xhigh | max\n isolation: worktree # (optional) run the subagent in an isolated git worktree\n background: false # (optional) run the subagent in the background\n initialPrompt: \"Start by reading the spec.\" # (optional) seed prompt for the subagent\n mcpServers: {} # (optional) MCP server config (passed through verbatim)\n hooks: {} # (optional) hook config (passed through verbatim)\ncopilot: # for GitHub Copilot specific parameters\n tools:\n # Listed tools are emitted verbatim; omit `tools` entirely to grant the agent\n # all tools. `agent/runSubagent` is opt-in — add it explicitly only when this\n # subagent needs to orchestrate other subagents.\n - web/fetch\n - agent/runSubagent\ncopilotcli: # for GitHub Copilot CLI specific parameters (generated to .github/agents/*.agent.md)\n model: claude-sonnet-4.6 # (optional) model id; inherits the session model when unset\n models: [\"claude-opus-5\", \"claude-sonnet-4.6\"] # (optional, CLI v1.0.81+) models in priority order; overrides `model`\n modelPolicy: preferred # (optional) preferred (default, overridable) | required (locks dispatch to the authored list)\n tools: [\"read\", \"edit\"] # (optional) tools available to the agent (string or list)\nopencode: # for OpenCode-specific parameters\n mode: subagent # (optional, defaults to \"subagent\") OpenCode agent mode\n model: anthropic/claude-sonnet-4-20250514\n variant: high # (optional) default model variant\n temperature: 0.1\n steps: 20 # (optional) max agentic iterations (supersedes the deprecated maxSteps)\n hidden: true # (optional) hide from the @ autocomplete menu (subagents only)\n color: \"#FF5733\" # (optional) hex color or theme color name\n tools:\n write: false\n edit: false\n bash: false\n permission:\n bash:\n \"git diff\": allow\nkilo: # for Kilo-specific parameters\n mode: all # (optional, defaults to \"all\") use \"subagent\" for hidden/subagent-only agents\ncursor: # for Cursor-specific parameters (generated to .cursor/agents/*.md)\n model: inherit # (optional, defaults to \"inherit\") model id, or \"inherit\" to use the parent's model\n readonly: false # (optional, defaults to false) restrict the subagent to read-only tools\n is_background: false # (optional, defaults to false) run the subagent as a background agent\n # On import, a `.cursor/agents/*.md` without `name` (Cursor makes every field optional) takes its name from the file name.\njunie: # for JetBrains Junie CLI specific parameters (generated to .junie/agents/*.md; also imported from .agents/*.md)\n tools: [\"Read\", \"Grep\", \"Edit\"] # allowed tools\n disallowedTools: [\"Bash\", \"WebSearch\"] # disallowed tools\n mcpServers: [\"github\"] # MCP servers the subagent may use\n model: sonnet # model id\n permissionMode: acceptEdits # (optional, defaults to \"default\") default | acceptEdits | dontAsk | bypassPermissions | plan\n reasoningLevel: high # low | medium | high\n maxTurns: 20 # max agentic turns\n skills: [\"kotlin\", \"writerside\"] # Agent Skills to utilize\n allowPromptArgument: true # whether the subagent accepts a prompt argument\ntakt: # takt specific parameters (optional; emitted under .takt/facets/personas/)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\nroo: # for Roo Code specific parameters (optional; aggregated into the root .roomodes file)\n slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name\n whenToUse: \"When planning a task\" # (optional) guidance for automated mode selection\n customInstructions: \"Be concise.\" # (optional) extra behavioral guidelines\n roleDefinition: \"You are the planner.\" # (optional) overrides the body as the mode's roleDefinition\n groups: # (optional, defaults to [\"read\", \"edit\", \"command\", \"mcp\"]) tool access\n - read\n - [\"edit\", { fileRegex: \"\\\\.md$\", description: \"Markdown files\" }]\nbob: # for IBM Bob specific parameters (optional; aggregated into .bob/custom_modes.yaml)\n slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name\n whenToUse: \"When planning a task\" # (optional) guidance for automated mode selection\n customInstructions: \"Be concise.\" # (optional) extra behavioral guidelines\n roleDefinition: \"You are the planner.\" # (optional) overrides the body as the mode's roleDefinition\n groups: # (optional, defaults to [\"read\", \"edit\", \"execute\", \"mcp\"]) Bob tool groups\n - read\n - skill\n allowedSubagents: [\"explore\"] # (optional) subagents the mode may spawn\npool: # for Pool specific parameters (optional; aggregated into the `subagents` block of .poolside/settings.yaml)\n type: in_process # (optional, defaults to \"in_process\") in_process | command | agent_server\n description: \"Plans a task\" # (optional) overrides the top-level description for Pool\n instructions: \"Plan carefully.\" # (optional) overrides the body as the agent's instructions\n command: my-agent # (command type) executable that speaks ACP over stdio\n args: [\"--acp\"] # (command type) arguments passed to the executable\n env: { LOG_LEVEL: debug } # (command type) environment variables for the process\n agent_server: my-agent # (agent_server type) name of a configured ACP agent server\n inherit_agent_config: true # (optional) inherit the parent's model and settings\n disabled: false # (optional) keep the definition but hide the agent from Pool\n---\n\nYou are the planner for any tasks.\n\nBased on the user's instruction, create a plan while analyzing the related files. Then, report the plan in detail. You can output files to @tmp/ if needed.\n\nAttention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code.\n```\n\n> **Claude Code nested subagents:** Rulesync recursively imports Markdown agents from `.claude/agents/` (and the equivalent global directory), preserves their relative paths under `.rulesync/subagents/`, and generates them back to the same nested paths. This makes an `import` followed by `generate --delete` round-trip safe for grouped agents such as `review/security-reviewer.md`. Markdown without valid agent frontmatter (for example, a nested `README.md`) is skipped with a warning during import and is never an orphan-deletion candidate; it stays in place next to valid agents. Imported nested agents are targeted at `claudecode` instead of `*`, because other tools may only support flat agent directories; add other targets explicitly only when they support that layout. When multiple files declare the same frontmatter `name`, the shallower path wins; ties are resolved lexicographically. File paths are also checked separately for case-insensitive collisions, so differently named agents at `Review/Agent.md` and `review/agent.md` cannot overwrite each other on macOS or Windows. Discovery follows symlinks consistently with the other tool import roots described above.\n\n> **Antigravity note:** Antigravity custom agents (CLI v1.1.6+, shared by the IDE and the CLI) are emitted as Markdown + YAML frontmatter to `.agents/agents/<name>.md` (project) and `~/.gemini/config/agents/<name>.md` (global, via `--global`); the body after the frontmatter is the agent's system prompt. Both `antigravity-ide` and `antigravity-cli` read the same two locations, so enabling both writes the same file — the same way they already share `.agents/hooks.json`. Antigravity also accepts a directory form (`<name>/agent.md`); Rulesync emits and imports the flat file form only. `name` and `description` are **required** upstream, so a canonical subagent without a description gets a minimal generated fallback rather than a file Antigravity refuses to load. Because the two share that file, every Antigravity target reads the `antigravity-ide` and `antigravity-cli` blocks merged in a fixed order (the CLI block wins) — the same rule the MCP feature uses for the same shared-output reason — so generation order never changes the file's content; the `antigravity-plugin` block is layered on top for the plugin bundle only. Besides the shared `name`/`description`, those blocks accept these optional fields (all preserved on round-trip): `tools` (string list), `mainAgent` (boolean, default `true`), `subagent` (boolean, default `true`), `model` (`inherit` | `flash` | `pro`), `commandExecutionPolicy` (`off` | `auto` | `eager` | `sandbox`), `mcpServers`, `skills`, and `plugins`. `hidden` and `inheritMcp` appear in the v1.1.6 release notes but not in the documented frontmatter table, so they pass through verbatim with no behavior modeled around them; the schema is loose, so any extra keys survive the round-trip too. The `antigravity-plugin` target writes the same file format into a plugin bundle's `agents/` directory (project scope only). See the [Antigravity subagents docs](https://antigravity.google/docs/subagents) and the [plugin bundle layout](https://antigravity.google/docs/cli/plugins).\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes AugmentCode subagents into a plugin bundle's `agents/<name>.md` (project scope only). Auggie names a plugin agent after its file and reads only `description`, `model` and `hidden` from its frontmatter, so Rulesync keeps those (plus the `name` Rulesync's format requires) and drops every other field in the `augmentcode` section with a warning. That includes `tools` and `disabled_tools`: an agent whose tools were restricted runs with the full tool set when shipped in a plugin, so keep such an agent on the `augmentcode` target if the restriction matters. A subagent whose `name` differs from its file name is warned about, since Auggie shows the file name. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **`.agents/agents/` ownership note (`agentsmd`):** `.agents/agents/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the cross-vendor location AGENTS.md-era clients actually scan for agent definitions: both Antigravity targets generate into it, and Kimi Code reads it alongside its own agents directory. The **simulated** `agentsmd` subagent writer therefore emits there too, and because it has no frontmatter model of its own it emits exactly what the Antigravity targets emit — same merged `antigravity-ide` → `antigravity-cli` blocks, same serialization — so a simulated writer can never degrade the file a native target owns. Earlier Rulesync versions wrote these files to `.agents/subagents/`, a directory no documented client reads; they are no longer generated there (stale outputs stay gitignored but are not cleaned up automatically), so move or delete anything you hand-authored under the old path. Note that, unlike the shared root Kimi Code reads (which is import-only for that target), `.agents/agents/` is a **generated** path for `agentsmd` and the Antigravity targets, so `generate --delete` sweeps orphans there: a hand-authored agent in that directory that no `.rulesync/subagents/*.md` produces is removed. Import it first if you want to keep it. A subagent pinned to one writer of this directory (`targets: [\"antigravity-cli\"]`, say) survives a run that includes the other writers: `generate` claims every path the run writes, across all the targets it runs, and holds the orphan sweep back until the last of them has written, so no writer of a shared directory treats a sibling's output as a leftover. Narrowing `--targets` narrows that claim set, though — a later `generate --targets antigravity-ide --delete` knows nothing about the `antigravity-cli`-pinned file and sweeps it — so generate the writers of a shared directory together. Because the file is shared, an `agentsmd`-only generate still emits the Antigravity blocks (`tools`, `model`, `commandExecutionPolicy`, `mcpServers`, …) verbatim: writing a reduced file would be exactly the silent degradation this shared-path arrangement exists to prevent. That is a deliberate trade-off — a subagent pulled in from someone else's repository with `rulesync fetch` becomes a live, executable agent definition as soon as any target that writes this path runs, so review `.rulesync/subagents/*.md` after fetching, exactly as you would review any other fetched configuration before generating.\n\n> **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides — accepts both a record of server specs, matching Qwen's documented shape, and a plain array of server names), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md).\n\n> **Tabnine CLI note:** Subagents are emitted as Markdown + YAML frontmatter under `.tabnine/agent/agents/` (project) and `~/.tabnine/agent/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Tabnine requires `name` and `description`, so a rulesync subagent without a description is written with the placeholder `<name> subagent` and a warning; the `tabnine:` block accepts the optional `kind` (`local` | `remote`), `tools` (allowlist of tool names), `model`, `temperature`, `max_turns` and `timeout_mins` fields, all preserved on round-trip together with any key Tabnine adds later. Subagents are an experimental Tabnine CLI feature that must be switched on with `experimental.enableAgents: true` in `settings.json` — Rulesync writes the agent files only and leaves that flag to you. See the [subagents docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/subagents).\n\n> **Snowflake Cortex Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.cortex/agents/` (project) and `~/.snowflake/cortex/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Cortex Code requires `name`; the `cortexcode:` block accepts the optional `description`, `tools` (allowlist of tool names) and `model` fields, all preserved on round-trip together with any key Cortex Code adds later. (Cortex Code also falls back to `.claude/agents/`, which the `claudecode` target already covers.) See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#subagents).\n\n> **Codewhale note:** Subagents are emitted as TOML agent profiles under `.codewhale/agents/<id>.toml` (project) and `~/.codewhale/agents/` (global, via `--global`). Rulesync writes `id` (the file name, with characters other than letters, digits, `.`, `_` and `-` replaced by `_`), `display_name` (the subagent `name`), `description`, and the body as `[instructions] text`. Codewhale rejects profiles with unknown fields, so only the `role_hint`, `base_role`, `loadout`, `model`, `provider` and `reasoning_effort` keys of the `codewhale` section are copied, and only when their value is a string; any other key or value is dropped with a warning. The `codewhale.tools` and `codewhale.permissions` tables become the profile's `[tools]` / `[permissions]` tables, but Codewhale rejects a profile that would widen access there, so only the narrowing values are written: `tools.posture` set to `read-only` (or `readonly` / `read_only`), and `permissions.allow_shell = false`, `permissions.trust = false` and `permissions.approval_required = true`; any other entry is dropped with a warning. As of Codewhale v0.10.1 these values match the floor every profile already gets, so they keep a profile loadable and round-trip through import rather than narrowing access further; effective access comes from `base_role` / `role_hint` and the parent session's posture. Codewhale also accepts `model_hint` / `model_id` for `model` and `thinking` / `reasoning` for `reasoning_effort`; those spellings are written under the canonical key (the canonical spelling wins when both are set). Import reads `display_name` (falling back to `name`, then `id`), `[instructions] text` (falling back to the legacy `persona`), the alias spellings into `model` / `reasoning_effort`, and the narrowing `[tools]` / `[permissions]` entries into the matching `codewhale` tables.\n\n> **Command Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.commandcode/agents/` (project) and `~/.commandcode/agents/` (user/global, via `--global`); the body is the agent's system prompt. Command Code requires `name`; `name` and `description` come from the rulesync subagent's top-level fields, and the `commandcode:` block accepts the documented optional fields — `tools` and `disallowedTools` (a comma/space-separated string or a YAML array, `\"*\"` grants every tool), `model`, `reasoningEffort`, `maxTurns`, `permissionMode`, `background` and `showOutput` — all preserved on round-trip together with any key Command Code adds later. Command Code reserves the names `explore`, `plan`, `review` and `general` for its built-in agents and silently ignores a custom agent whose `name` — sanitized to `[a-zA-Z0-9_-]` and lowercased, so `Review` counts — is one of them; rulesync still writes such a subagent but warns, since the name is yours to change. See the [custom agents docs](https://commandcode.ai/docs/custom-agents).\n\n> **Qoder note:** Subagents are Markdown + YAML frontmatter under `.qoder/agents/` (project) and `~/.qoder/agents/` (global, via `--global`); the body is the subagent's system prompt, and its identity is the frontmatter `name`, not the file name. `name` and `description` come from the rulesync subagent's top-level fields, and the `qoder:` block accepts Qoder's optional fields — `tools` and `disallowedTools` (a comma-separated string or a YAML list), `model`, `permissionMode`, `maxTurns`, `timeoutMins`, `color`, `skills`, `isolation`, `mcpServers`, `hooks` and any key Qoder adds later — all preserved on round-trip. See the [subagent docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/subagent).\n\n> **Letta Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.letta/agents/` (project) and `~/.letta/agents/` (user/global, via `--global`); the body is the subagent's system prompt, and a project subagent overrides a user one of the same name. Letta Code requires `name` and `description`, both taken from the rulesync subagent's top-level fields; the `lettacode:` block carries the optional fields Letta Code reads — `tools` (a comma-separated string such as `Glob, Grep, Read`, or `all`), `model`, `skills`, `fork` and `launchProfile` — and any other key, all preserved on round-trip. Letta Code reads `tools` and `skills` only as comma-separated strings (a YAML list would be ignored, which for `tools` means every tool), so a list is written joined with `, `. Letta Code reads frontmatter with its own line-based parser, which does not unquote values, so a string that YAML would have to quote (a description containing `: `, say) is written as a `|-` block scalar instead. Letta Code skips a subagent whose `name` is not lowercase letters, digits and hyphens starting with a letter, one named `claude-code` or `codex` (reserved), and one without a `description` (unless its body is empty, which overlays the built-in subagent of that name); Rulesync still writes such a subagent but warns. See the [subagents docs](https://docs.letta.com/configuration/subagents/index.md).\n\n> **Kimi Code note:** Subagents are emitted as Markdown files under `.kimi-code/agents/` (project) and `~/.kimi-code/agents/` (global). The shared `name` and required `description` fields are written to YAML frontmatter; Kimi-specific `whenToUse`, `override`, `tools`, `disallowedTools`, and `subagents` fields can be authored under the `kimi-code:` block and round-trip unchanged. Kimi recursively scans both its Kimi-specific agents directory and the shared `.agents/agents/` directory, so Rulesync imports nested Markdown files from both locations and flattens them into `.rulesync/subagents/<name>.md` using the validated kebab-case agent name. The Kimi-specific root has precedence over `.agents/agents/`; if multiple source files resolve to the same logical agent name, the first one wins and Rulesync warns about the duplicate. The shared root is import-only and is never removed by Kimi-target orphan deletion. See the [Kimi Code custom-agents docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html). The `kimi-code-plugin` target writes the same format into a plugin bundle's `agents/<name>.md` (project scope only); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **Kiro CLI note:** Subagents are emitted as JSON agent configurations under `.kiro/agents/` (project) and `~/.kiro/agents/` (global). Kiro allows the JSON `name` field to be omitted, in which case the filename stem is the agent name; Rulesync accepts that form on import and writes the derived name into the Rulesync frontmatter. Imports through the `kiro-cli` target retain `targets: [\"kiro-cli\"]`, so they can be generated back to the same target without changing the target metadata.\n\n> **Cline note:** Cline file-based agents are emitted as YAML files (`<name>.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block followed by the system prompt body, matching Cline's agent config loader: `name` and `description` are **required** (Cline cli-v3.0.23+ refuses to load an agent whose `description` is missing or empty — a canonical subagent without one gets a minimal generated fallback rather than a file Cline cannot load), and the typed optional fields `tools`, `skills`, `providerId`, `modelId`, and `maxIterations` round-trip through the `cline:` section. Import reads `.yml` alongside `.yaml`, matching Cline's `isYamlFile()`.\n\n> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents/<name>/AGENT.md` (project) and `~/.config/devin/agents/<name>/AGENT.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). The `devin-plugin` target writes the same format into a plugin bundle's `agents/<name>/AGENT.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Reasonix note:** Reasonix native subagents are Skill profiles emitted as `SKILL.md` files in a **directory-per-agent** layout: `.reasonix/skills/<name>/SKILL.md` (project) and `~/.reasonix/skills/<name>/SKILL.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). A subagent is a Skill whose YAML frontmatter declares `invocation: manual` and `runAs: subagent` — Rulesync always injects both markers so the SKILL.md is recognized as a manually invoked subagent rather than an auto-discovered skill. Besides the shared `name`/`description`, the `reasonix` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, subagent LLM), `effort` (string, reasoning effort), `allowed-tools` (list of strings, restrict available tools), and `color` (string, display color). The schema is loose, so any extra keys survive the round-trip. See the [Reasonix subagent profiles docs](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SUBAGENT_PROFILES.md).\n\n> **Roo / Zoo Code mode-specific rules note:** Both tools load `.roo/rules-{mode}/` (global `~/.roo/rules-{mode}/`) **instead of** the mode-agnostic `.roo/rules/` while that custom mode is active. Set a `roo:` frontmatter section with `mode: architect` on a rule to route it there; the section is shared by the `roo` and `zoocode` targets, which write the same `.roo/` tree. The slug must match `^[a-zA-Z0-9-]+$` (the alphabet the tools themselves accept) — anything else is ignored with a warning and the rule lands in `.roo/rules/`, rather than interpolating an arbitrary string into a directory name. The key is ignored on the `root: true` rule, which has no mode-specific counterpart. On import, a file under a mode directory comes back as `.rulesync/rules/{name}-{mode}.md` carrying `roo.mode`, suffixed so it cannot collide with a same-named generic rule (an already-suffixed name is left alone, so repeated generate/import cycles converge) and targeted at the importing tool alone, since a wildcard would make every other target emit a mode-scoped rule as an always-on one; mode-directory import is project scope only. Note that mode directories are **not swept by orphan deletion** — `generate --delete` only clears `.roo/rules/` — because a `rules-*` glob would also match mode rules you wrote by hand, so a file left behind by a removed rule has to be deleted yourself. Mode-specific _skills_ need no directory support: Zoo Code reads `modeSlugs` from a skill's own frontmatter at higher priority than the directory it sits in, so the existing `roo: modeSlugs` frontmatter already scopes a skill in `.roo/skills/` to a mode.\n\n> **Roo skills/commands note (final v3.54.0 state — Roo Code is EOL and its repository archived):** Commands are generated to `.roo/commands/` (project) and `~/.roo/commands/` (global, via `--global`; project wins on a name collision). Skill frontmatter beyond `name`/`description` — most usefully `modeSlugs: string[]` for mode targeting — is authored via the `roo:` section of `.rulesync/skills/*/SKILL.md` and lifted back into it on import, so it survives the round-trip. A localRoot rule is emitted as `AGENTS.local.md`, the personal, gitignored override file Roo loads alongside `AGENTS.md`.\n\n> **Zoo Code note:** Zoo Code ([Zoo-Code-Org/Zoo-Code](https://github.com/Zoo-Code-Org/Zoo-Code)) is the community continuation of the archived Roo Code, named by the Roo shutdown notice and continuing Roo's release numbering (v3.54.0 → v3.72.0 as of 2026-07-25). It still resolves `~/.roo` and the project `.roo/` layout — the `.zoo` renaming is confined to provider/auth code — so the `zoocode` target reuses the `roo` adapters' path model verbatim across rules (including `AGENTS.local.md` local-root handling), ignore (`.rooignore`), MCP (`.roo/mcp.json`), commands (`.roo/commands/`), skills (`.roo/skills/`, `roo:` frontmatter section), and subagents (the aggregated `.roomodes` file). Shared mode/skill fields keep riding the `roo:` frontmatter sections, so one rulesync source never produces two spellings; targeting both `roo` and `zoocode` writes the same files, so pick one target per project — and note the fail-open hazard the shared `.roomodes` creates: a `--targets roo` generate rewrites it **without** `allowedMcpServers`, so opening that workspace in Zoo Code makes every MCP server available to the mode. The post-fork divergence is carried by the `zoocode:` subagent section: `allowedMcpServers` (Zoo Code v3.60.0+), a per-mode MCP server allowlist (\"when omitted, all servers are available; when set, only the listed servers are injected\"), emitted into the mode and lifted back into `zoocode:` on import. See the [Zoo Code docs](https://docs.zoocode.dev/features/custom-modes).\n\n> **Vibe note:** Vibe agent profiles are emitted as TOML to `.vibe/agents/<name>.toml` (project) and `~/.vibe/agents/<name>.toml` (global, via `--global`). The subagent body is **not** written into the profile: Vibe's settable field is `system_prompt_id`, while `system_prompt` is a read-only property on its config schema and unknown TOML keys are ignored rather than rejected — so a profile carrying `system_prompt` loads fine and silently runs with the **default** system prompt. Rulesync therefore writes the body to `.vibe/prompts/<name>.md` and sets `system_prompt_id = \"<name>\"`, the same mechanism Vibe's own builtin profiles use (`EXPLORE` sets `\"system_prompt_id\": \"explore\"`). The two files are always written together, because `VibeConfigSchema._check_system_prompt` evaluates the id during validation and an unresolvable one makes `AgentRegistry._try_load` drop the agent with a warning. On import, `system_prompt_id` is resolved against `.vibe/prompts/` and becomes the canonical body; a legacy `system_prompt` is still read, and an id that resolves to nothing is preserved in the `vibe:` block so a hand-maintained prompt file keeps working. A subagent with an empty body writes no prompt file and leaves any `system_prompt_id` you authored alone. Note that `.vibe/prompts/` is not swept by orphan deletion — `generate --delete` only clears `.vibe/agents/` — so a prompt file left behind by a removed subagent has to be deleted by hand. The `vibe-plugin` target writes subagents into a plugin bundle's `ai.mistral.vibe/agents/<name>.toml` (project scope only), in Vibe's plugin agent document with the prompt inline as `instructions`. See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Roo note (as of 2026-06-16):** Roo Code reads project custom modes from a single aggregated `.roomodes` file at the workspace root (YAML; JSON also accepted). Rulesync therefore collapses every Roo-targeted subagent into that file's `customModes` array — each subagent becomes one mode whose `slug` is derived from the file name (sanitized to `^[a-zA-Z0-9-]+$`), `name`/`description` come from the shared frontmatter, and `roleDefinition` is the subagent body. The optional `roo:` block supplies `groups` (defaults to `[\"read\", \"edit\", \"command\", \"mcp\"]`), `whenToUse`, `customInstructions`, an explicit `slug`, and a `roleDefinition` override. (Roo's previous `.roo/subagents/` output was inert — Roo Code never read it.) See the [Roo custom-modes docs](https://roocodeinc.github.io/Roo-Code/features/custom-modes).\n\n> **IBM Bob note:** IBM Bob's native subagents are two fixed presets (`explore` and `general`), so Rulesync maps subagents onto Bob's custom modes instead, the same way the `roo` target fills `.roomodes`. Every Bob-targeted subagent becomes one entry in the `customModes` array of the project file `.bob/custom_modes.yaml` (project scope only: Bob IDE documents `~/.bob/settings/custom_modes.yaml` as the global file while Bob Shell documents `~/.bob/custom_modes.yaml`, so no global file is written). The `slug` is derived from the file name (sanitized to `^[a-zA-Z0-9-]+$`), `name`/`description` come from the shared frontmatter, and `roleDefinition` is the subagent body. The optional `bob:` block supplies `groups`, `whenToUse`, `customInstructions`, `allowedSubagents`, an explicit `slug`, and a `roleDefinition` override. It is separate from the `roo:` block because Bob spells its tool groups differently (`read`, `edit`, `execute`, `mcp`, `skill`, `workflow`, `todo`, `subtask`, `subagent`, `mode`, as Bob IDE documents them, where Roo has `command` instead of `execute`); without `bob.groups` a mode gets `[\"read\", \"edit\", \"execute\", \"mcp\"]`. The Bob Shell custom-modes page still lists Roo's `read`, `edit`, `browser`, `command`, `mcp` instead, so set `bob.groups` explicitly for a mode you use in Bob Shell. On import every mode comes back as its own `.rulesync/subagents/<slug>.md` targeted at `bob`, with the other mode fields in the `bob:` block. Rulesync owns the whole file: generating rewrites it from `.rulesync/subagents/`, and `generate --delete` removes it when no subagent targets `bob`, so import any hand-written modes first. See the [Bob custom modes docs](https://bob.ibm.com/docs/ide/configuration/custom-modes).\n\n> **OpenCode import note:** OpenCode lets agents live both as Markdown files under `.opencode/agents/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `agent` key. On import, rulesync reads both: each inline entry's `prompt` becomes the subagent body (a `\"{file:./path}\"` reference is resolved relative to the config file's location, as OpenCode does), and the remaining fields (`description`/`mode`/`model`/`tools`/`permission`/...) become frontmatter under the `opencode:` block. Entries under the OpenCode V2 spelling `agents` are read too, lowered the way OpenCode V1 (from v1.18.24) lowers them: `system` becomes the body, `disabled` becomes `disable`, `request.body` becomes `options`, a `provider/model#variant` model (or a `{ providerID, model, variant }` object) is split into `model` and `variant`, other V2-only fields (such as `permissions`) are not read, and an `agent` entry wins over an `agents` entry of the same name. A Markdown file takes precedence over an inline entry with the same name, compared case-insensitively — `.opencode/agents/planner.md` and an inline `Planner` are one `.rulesync/subagents/` file on macOS and Windows, so the inline copy is dropped with a warning rather than silently overwriting the file (two inline entries differing only in case resolve the same way, keeping the earlier one).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) reads the OpenCode agent format from `.mimocode/agents/*.md` (project) / `~/.config/mimocode/agents/*.md` (global, via `--global`). Put its tool-specific frontmatter under a `mimocode` key, which takes the same fields as the `opencode` key; on import, inline `agent` entries in `.mimocode/mimocode.json(c)` are read as for OpenCode, except the V2 `agents` spelling, which MiMo Code does not load.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads Markdown agent definitions from `.omp/agents/*.md` (project) / `~/.omp/agent/agents/*.md` (global, via `--global`). It ignores an agent without both `name` and `description`, so Rulesync falls back to the `name` when a subagent has no `description`. Put oh-my-pi's own frontmatter (`tools`, `model`, `spawns`, `thinkingLevel`, `blocking`, …) under an `omp` key; it is copied verbatim. The names `main` and `sub` are reserved by oh-my-pi.\n\n> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior.\n\nBesides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip):\n\n| Field | Type | Notes |\n| ------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `displayName` | string | Human-friendly name shown in pickers |\n| `model` | string | Model id |\n| `variant` | string | Model variant |\n| `temperature` | number | Sampling temperature |\n| `top_p` | number | Nucleus-sampling parameter |\n| `permission` | string \\| object | Permission profile name, or a per-tool `{ <tool>: { allow, deny, ask } }` object |\n| `prompt` | string | Inline system prompt |\n| `color` | string | UI color |\n| `native` | boolean | Native (built-in) agent flag |\n| `hidden` | boolean | Hide from top-level picker |\n| `disable` | boolean | Disable the agent |\n| `deprecated` | boolean | Mark as deprecated |\n| `steps` | positive integer | Maximum agentic iterations before a text-only response is forced (an explicit `null` is accepted and round-trips as-is, so a file that already carries one still imports; earlier Rulesync versions took a list of step objects here, which Kilo never accepted) |\n| `options` | object | Free-form key/value options |\n\n> **Migration note (`steps`):** earlier Rulesync versions typed `steps` as a list of step objects, which Kilo never accepted — a subagent authored that way produced a file Kilo ignored. It is now the iteration count Kilo documents, so a `kilo` block (or a `.kilo/agents/*.md` file) still carrying the list form fails validation with the offending file named, and the run stops rather than writing a file that would not work. Replace the list with the number of iterations you want, or drop the field.\n\n> **Hermes Agent note:** Project generation writes subagent JSON specs under `.hermes/rulesync/subagents/` and installs `.hermes/plugins/rulesync-subagents/`. The plugin resolves specs relative to its own installation, so the same code works in project and global scope. For project scope, Rulesync also enables `rulesync-subagents` in `$HERMES_HOME/config.yaml`. Run Hermes from the trusted project root with `HERMES_ENABLE_PROJECT_PLUGINS=true`; Rulesync deliberately does not persist that global trust gate.\n\n> **Pool note:** Pool has no per-agent files: every subagent is an entry of the `subagents.agents` map in its settings file, `.poolside/settings.yaml` for the project and `~/.config/poolside/settings.yaml` for `--global`. Rulesync writes one entry per subagent, keyed by the frontmatter `name`, with `type` (defaulting to `in_process`), `description` and `instructions` (the Markdown body, unless `pool.instructions` overrides it) plus whatever else the `pool:` block carries, such as `command`/`args`/`env` for a `command` agent or `agent_server` for an `agent_server` one. Rulesync owns the whole `subagents` block: on every run it recomputes `subagents.agents` from the Rulesync subagents targeting Pool, dropping agents a previous run generated but keeps `subagents.default` and Pool's built-in `general` entry (a Rulesync subagent named `general` is skipped with a warning) and leaves the file's other keys untouched; a run with no Pool-targeted subagent therefore removes the generated agents instead of leaving stale ones behind, as long as a `.rulesync/subagents/` directory exists (a project without one has not adopted the feature and its agents are left alone, and `rulesync convert --to pool` likewise never retracts, since its sources come from another tool). Every agent Rulesync removes is named in a warning. Hand-written agents that should survive generation belong in `.poolside/settings.local.yaml`, which Rulesync never touches; at global scope, where Pool has no such overlay, keep them in `.rulesync/subagents/` instead. `rulesync import --targets pool` fans `subagents.agents` (except `general`) back out to one `.rulesync/subagents/<name>.md` per agent, moving `instructions` into the body and the remaining keys into the `pool:` block. See the [Pool subagents docs](https://docs.poolside.ai/subagents) and [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **ZCode note:** ZCode subagents are written to `.zcode/agents/<name>.md` (project) and `~/.zcode/agents/<name>.md` (global, via `--global`). ZCode's docs describe only the user directory — the Settings UI manages user-level subagents alone — but its agent runtime also loads `<project>/.zcode/agents/` (observed in the v3.14.3 runtime; not yet documented). Project-scope subagents are parsed like user ones except that `permissionMode` is discarded, so a `permissionMode` in the `zcode:` block is written for `--global` only and dropped with a warning in project mode. Each subagent is one Markdown file named after the agent, with YAML frontmatter whose keys are camelCase and case-sensitive: `name` and `description` are required, and `model`, `thoughtLevel`, `color`, `tools`, `disallowedTools`, `maxTurns` (a positive integer), `injectAgentsMd` and `mcpServers` are optional. Put those optional fields in the `zcode:` block of the canonical subagent; any extra keys are preserved on round-trip through the same block. See the [ZCode subagents docs](https://zcode.z.ai/en/docs/subagents). The `zcode-plugin` target writes the same format into a plugin bundle's `agents/<name>.md` (project scope only); ZCode reads plugin agents like `~/.zcode/agents/` ones, so `permissionMode` is kept there. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **CodeBuddy Code note:** Subagents are written to `.codebuddy/agents/<name>.md` (project) and `~/.codebuddy/agents/<name>.md` (global, via `--global`) with the required `name` and `description`, both taken from the canonical subagent (same-named keys in the `codebuddy:` block are ignored, so `name` always matches the file name). The optional keys CodeBuddy documents (`tools`, `model`, `permissionMode`, `skills`, `mcpServers`, `disallowedTools`, `effort`, `maxTurns`, `background`, `initialPrompt`, `memory`, `hooks`) go in the `codebuddy:` block and are written verbatim, so `tools` can stay the comma-separated string CodeBuddy's docs use. See the [CodeBuddy Code sub-agents docs](https://www.codebuddy.ai/docs/cli/sub-agents).\n\n## `.rulesync/checks/*.md`\n\nCode review checks are per-check instructions an agent runs during code review. Each check is a single Markdown file with YAML frontmatter (the source of the check identity is the file name — e.g. `.rulesync/checks/security.md` defines the `security` check).\n\nExample:\n\n```md\n---\ntargets: [\"*\"] # * = all, or specific tools\ndescription: Flags common security issues # (optional) short summary of the check\nseverity: high # (optional) low | medium | high | critical\ntools: [\"Read\", \"Grep\"] # (optional) tool names the check may use\n---\n\nReview the diff for injection vulnerabilities, hardcoded secrets, and unsafe\ndeserialization. Report each finding with a file and line reference.\n```\n\nAmp, AugmentCode, Cursor, Factory Droid, GitLab Duo CLI, Goose (global only), Hermes Agent, Kilo Code, Qwen Code, Rovo Dev CLI and Takt consume checks. Amp receives one Markdown file per check:\n\n- **Project scope:** `.agents/checks/<name>.md`\n- **Global scope** (`--global`): `~/.config/amp/checks/<name>.md`\n\nFor Cursor, checks are [Bugbot](https://cursor.com/docs/bugbot) code review instructions, and Bugbot reads one aggregated instruction file per directory rather than a file per check — so every check targeting Cursor collapses into the repository-root `.cursor/BUGBOT.md`. Each check becomes one section: an HTML-comment marker carrying the check name, an `## <name>` heading, and the check body as the instruction text (the `description` is used when the body is empty). Bugbot reads the file as free prose, so a check's `severity` and `tools` have no equivalent there — they are not written and do not come back on import, and neither is `description` whenever the check also has a body. Project scope only: Bugbot reads repository files and there is no user-level instruction file. Because Bugbot only sees the file when it is **committed**, the derived `.gitignore` deliberately does not ignore `.cursor/BUGBOT.md` (Rovo Dev's `.rovodev/.review-agent.md` and Factory Droid's `.factory/skills/review-guidelines/SKILL.md` get the same treatment) — commit the generated file for the reviewer to pick it up. Example output:\n\n```md\n<!-- rulesync:check:security -->\n\n## security\n\nReview the diff for injection vulnerabilities.\n```\n\nOn import the markers split the file back into one check per section, each with `targets: [\"*\"]` because Bugbot instructions are plain prose that applies anywhere. Content sitting ahead of the first marker — and a hand-written `BUGBOT.md` with no markers at all — is imported as a single `bugbot` check, so nothing in the file is dropped. A check body that contains a marker line of its own (a quoted rulesync doc fragment, say) is written as `<!-- rulesync:literal-check:… -->` and restored on import, so it cannot split the check it belongs to. Bugbot also merges nested `<dir>/.cursor/BUGBOT.md` files found while traversing upward from changed files, but rulesync check sources carry no directory-placement semantics, so only the root file is generated.\n\nGenerating checks for Cursor replaces `.cursor/BUGBOT.md`, so run `rulesync import --targets cursor --features checks` first if the repository already has a hand-written one — generation warns when it is about to replace instructions rulesync did not write. Deletion is guarded: a `BUGBOT.md` holding anything rulesync did not write — no marker at all, or hand-written text ahead of the first marker — is never removed, so dropping the last check that targets Cursor takes rulesync's own output with it and nothing else.\n\nFor Rovo Dev CLI, checks are [code-review custom instructions](https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/), and Rovo Dev reads one plain-Markdown file rather than a file per check — so every check targeting Rovo Dev collapses into `.rovodev/.review-agent.md` (note the leading dot in the file name). The file takes **no frontmatter**. Everything else works exactly as it does for Cursor Bugbot above, because the two surfaces are the same shape: one marked-up section per check, `severity`/`tools` dropped, `description` used only when the body is empty, markers splitting the file back on import (with a hand-written file importing as a single `review-agent` check), the same `<!-- rulesync:literal-check:… -->` escaping, the same replace-and-warn on generate, and the same deletion guard for a file holding anything rulesync did not write. Project scope only — these are per-repository review instructions and Rovo Dev documents no user-level equivalent, which is the opposite of the Rovo Dev permissions surface (global only).\n\nFor Kilo Code, checks are the [repository guidance file](https://kilo.ai/docs/automate/code-reviews) that Kilo Code Reviews reads, so every check targeting Kilo collapses into the repository-root `REVIEW.md`. It works like Rovo Dev's file above — one marked-up section per check, no frontmatter, `severity`/`tools` dropped, `description` used only when the body is empty, markers splitting the file back on import (a hand-written file imports as a single `review` check), and the same deletion guard — with one difference: `REVIEW.md` is a generic root-level name other reviewers may read too, so a hand-written `REVIEW.md` is **left untouched** with a warning instead of being replaced (import it first, then delete it, to let rulesync own it). Kilo reads the file from the PR/MR **base branch**, so the derived `.gitignore` does not ignore it — commit it — and the reviewer only uses it once **Use REVIEW.md** is enabled in the Code Reviews settings of the Kilo web app, which rulesync cannot toggle. Project scope only.\n\nFor Qwen Code, checks are the [project review rules](https://qwenlm.github.io/qwen-code-docs/en/users/features/code-review/) that `/review` injects into its review agents as additional criteria, and Qwen Code reads one plain-Markdown file rather than a file per check — so every check targeting Qwen Code collapses into `.qwen/review-rules.md`. The file takes **no frontmatter**, and everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, `severity`/`tools` dropped, `description` used only when the body is empty, a hand-written file importing as a single `review-rules` check, the same escaping, replace-and-warn on generate, and the same deletion guard). `/review` reads `.qwen/review-rules.md` ahead of its fallbacks (`.github/copilot-instructions.md`, then a `## Code Review` section in `AGENTS.md` / `QWEN.md`), and for PR reviews it reads the rules from the **base branch**, so the derived `.gitignore` deliberately does not ignore the file — commit it, and the rules take effect for pull requests once they reach the base branch. Project scope only: there is no user-level rules file. (Qwen Code's `review.*` settings are reviewer policy honored only in user/system settings, not review criteria, so global checks are not written there.)\n\nFor Factory Droid, checks are its [repository-specific review guidelines](https://docs.factory.ai/software-factory/code-review-ci). Factory's automated code review has no dedicated instruction file: it reads a skill named `review-guidelines` and injects it into every review run, so every check targeting Factory Droid collapses into `.factory/skills/review-guidelines/SKILL.md`. The file is plain Markdown with **no frontmatter**, matching Factory's documented example — and frontmatter would be self-defeating anyway, since anything ahead of the first marker counts as hand-written text. Everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, `severity`/`tools` dropped, the same escaping, the same deletion guard), with a hand-written file importing as a single `review-guidelines` check (its YAML frontmatter, if it has any, is skill metadata rather than review prose and is dropped on the way in). Project scope only: the reviewer runs against a repository and reads the file out of it, so there is no user-level equivalent to write. Because the output lives inside the `.factory/skills/` tree the `skills` feature also writes, the derived `.gitignore` ignores that tree as `**/.factory/skills/**` and re-includes this one file — git cannot un-ignore a path inside an ignored directory, so the directory pattern has to be the broader one. Re-run `rulesync gitignore` after upgrading: an earlier rulesync wrote the directory itself as `**/.factory/skills/`, and git never descends into an ignored directory, so that one line left in place would keep the re-include from working and the reviewer would never see the generated file. The command takes the old spelling out for you, wherever in the file it sits. A `review-guidelines` skill you authored yourself collides with the generated file. Generation does not replace it: a file holding anything ahead of the first generated marker is left exactly as it is, no Factory Droid checks are written at all for that run, and a warning names the file — the path has a single owner rather than a merge rule, and a file here may be an ordinary skill written for Droid's skill loader rather than review prose, so rulesync does not rewrite it (this is stricter than Cursor Bugbot's replace-and-warn, whose path only the reviewer reads). Import it with `rulesync import --targets factorydroid --features checks` and then delete the file: importing copies the text into `.rulesync/checks/` but leaves the file itself alone, so generation stays blocked until it is gone, and the next generate writes the path back from `.rulesync/checks/`. Delete it outright if you no longer want it, or rename the directory if what it holds is an ordinary skill rather than review guidelines. The `skills` feature leaves this path alone in every direction, whatever the file holds: a project-scoped `review-guidelines` skill is never imported as a skill, never swept as an orphan skill directory by `generate --delete`, and never generated into `.factory/skills/review-guidelines/` in the first place — a rulesync skill of that name is skipped for Factory Droid with a warning, so rename it if you want it generated. Global scope is unaffected, since checks has no user-level output: `~/.factory/skills/review-guidelines/` is an ordinary skill. Deleting the file when the last check targeting Factory Droid goes away is still guarded: it is removed only when it holds nothing but generated sections. Note that this file is **committed**, unlike the rest of `.factory/skills/`: Factory's reviewer reads it out of the checked-out repository, so whatever it contains is injected into every review run — review it as you would any other committed instruction file, in particular when it came from `rulesync fetch`.\n\nFor AugmentCode, checks are [Augment Code Review guidelines](https://docs.augmentcode.com/codereview/review-guidelines), which live in one YAML file at `.augment/code_review_guidelines.yaml` rather than in a file per check. Augment groups rules into named **areas**, each with a `description`, the `globs` it applies to, and a list of `rules` — every rule an `id` / `description` / `severity` triple, all of them required. Rulesync maps one check onto one rule: the body becomes the rule's `description` (the check's `description` is used when the body is empty, and the file stem when neither is set), and the rule lands in an area of its own, keyed by the check's file stem. An `augmentcode` frontmatter block moves it: `area` groups several checks under one key, with `areaDescription` and `globs` taken from the first check to name that area (`globs` defaults to `[\"**\"]`, matching Augment's own example; an authored empty list is kept as written, since an area matching nothing is a narrower statement than the catch-all, not an absent value), and `id` overrides the rule id. An authored `area` is used verbatim — Augment's own documented example keys an area `memory_safety`, and rewriting the underscores would leave the original area behind while a second one appeared beside it. Only the file-stem default is slugified, since that has to become a legal key from an arbitrary file name. Rule ids are kept distinct because Augment reports findings by id: two same-named checks in different subdirectories become `security` and `security-2`, and a generated id also steps aside for one a preserved hand-written area already uses. Example:\n\n```md\n---\ntargets: [\"augmentcode\"]\nseverity: high\naugmentcode:\n area: databases\n areaDescription: \"Data and Database related rules\"\n globs: [\"db/**\"]\n id: \"no_pii_in_bigquery\"\n---\n\nNever store PII data in BigQuery tables.\n```\n\n**Severity is lossy in one direction.** Augment's scale is `high` / `medium` / `low` with no band above `high`, so canonical `critical` is written as `high` and imports back as `high` — the canonical value is not recoverable from Augment's file alone. A check with no `severity` emits `medium`, since the field cannot be omitted: `high` would push every unannotated check past the ones deliberately marked `medium`, and `low` would bury them.\n\nGeneration **merges** rather than replaces, because Augment's documentation tells users to hand-write this file. Only the areas the current check set claims are rewritten — and a claimed area is replaced as a whole, so a field you hand-added inside one Rulesync regenerates does not survive. Every other area, the `file_paths_to_ignore` list, and any key Augment adds later are left untouched. `file_paths_to_ignore` is recognized and preserved but never authored or imported — the canonical check model has no ignore surface, and adding one is a separate question. The cost of merging is that rulesync cannot tell its own leftovers from a hand-written area: renaming a check strands the area under the old key, and when checks remain but none target AugmentCode the existing areas are left in place with a warning rather than guessed at. For the same reason the file is never deleted once it exists — unlike the Markdown surfaces, YAML carries no marker saying which text is rulesync's, since a rewrite drops comments and an unknown top-level key risks Augment's own parser.\n\nOn import, each rule becomes its own check (an area of three rules is three checks, not one), carrying the area key, description and globs back in its `augmentcode` block so the next generate regroups them exactly where they were. A rule missing `id` or `description` is left in the YAML rather than imported, and a rule id repeated across two areas is suffixed so the second check does not overwrite the first. Project scope only — the reviewer reads the file from the committed repository, and Augment documents no user-level equivalent.\n\nFor GitLab Duo CLI, checks are [custom review instructions](https://docs.gitlab.com/user/duo_agent_platform/customize/review_instructions/) for the Code Review Flow, which live in one YAML file at `.gitlab/duo/mr-review-instructions.yaml` (project scope only; the review reads it from the repository, so the file is committed rather than gitignored). The file is a list of instruction groups, each a `name`, optional `fileFilters` globs, and the `instructions` text, and every check targeting GitLab Duo becomes one group: `name` is the check's file name (without `.md`) unless `gitlabduo.name` sets it — GitLab quotes it in review comments as `According to custom instructions in '<name>'` — `fileFilters` comes from `gitlabduo.fileFilters` (omitted means every file), and the body is the `instructions` (falling back to `description`). GitLab has no severity or tool fields, so `severity` and `tools` are not written.\n\n```md\n---\ntargets: [\"gitlabduo\"]\ngitlabduo:\n name: \"Ruby Style Guide\"\n fileFilters: [\"*.rb\", \"lib/**/*.rb\", \"!spec/**/*.rb\"]\n---\n\nFollow the Ruby style guide and flag N+1 queries.\n```\n\nAs for AugmentCode, generation **merges** rather than replaces, because GitLab documents the file as hand-written: a group whose `name` a check claims is replaced in place, other groups and top-level keys are left untouched, and new groups are appended. The file is re-serialized on write, so YAML comments and quoting or block-scalar styles in it are not preserved. Renaming a check therefore leaves the group under the old name behind, and with no GitLab-targeting check at all the file is left alone with a warning; an existing file is never deleted. On import, each group with a `name` and `instructions` becomes one check carrying its `name` and `fileFilters` in the `gitlabduo` block.\n\nFor Hermes Agent, Rulesync writes project-local JSON specs under `.hermes/plugins/rulesync-checks/checks/` and a `rulesync-checks` plugin beside them. Its one-shot [`pre_verify` hook](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/#pre-verify) fires only for coding turns with changed paths and `attempt == 0`, then asks Hermes to run all configured checks before finishing. `tools` is preserved as advisory guidance because Hermes does not enforce an Amp-style per-check tool allowlist. Run Hermes with the project plugin explicitly trusted for that invocation:\n\n```sh\nHERMES_ENABLE_PROJECT_PLUGINS=1 hermes\n```\n\nRulesync adds `rulesync-checks` to `plugins.enabled` in `$HERMES_HOME/config.yaml` but deliberately leaves `$HERMES_HOME/.env` unchanged, preserving Hermes's global trust boundary. Existing plugin configuration is preserved; an explicit `plugins.disabled` conflict fails generation.\n\nFor Takt, checks are **quality gates**, and they live in the `workflow_overrides` block of the shared `.takt/config.yaml` (project) / `~/.takt/config.yaml` (global) rather than in files of their own — so every check targeting Takt collapses into that one file. A check becomes one gate: by default a **string gate**, the body text, which Takt injects into the agent step prompt as a completion directive (the `description` is used when the body is empty, and the file stem when neither is set); with `command` in the check's `takt` frontmatter block, a **command gate** (`{type: command, name, command, cwd, timeout_ms}`), which Takt runs after the step and fails on a non-zero exit code. `name` defaults to the file stem so Takt's logs identify the gate. `name`, `cwd` and `timeout_ms` belong to a command gate, so they are ignored on a check that states no `command`. `steps` and `personas` in that block scope a gate to named workflow steps or personas (`workflow_overrides.steps.<step>.quality_gates`); an unscoped gate applies everywhere, and a gate naming both is written to both. `quality_gates_edit_only` is a property of the block as a whole, so one check setting it turns it on for all of them. It reaches only the unscoped gates — Takt runs a `steps`/`personas`-scoped gate whether or not the step may edit files — so the reach it narrows is the other checks' unscoped gates, which is warned about when there are any. Takt gates carry no severity or tool allowlist, so a check's `severity` and `tools` are not written and do not come back on import. Takt merges quality gates additively and dedupes them (project over global over the workflow YAML's own gates). Example:\n\n```md\n---\ntargets: [\"takt\"]\ntakt:\n command: ./.takt/quality-gates/check.sh # omit for a string gate\n timeout_ms: 300000\n steps: [\"review\"] # (optional) scope to named workflow steps\n personas: [\"coder\"] # (optional) scope to named personas\n---\n```\n\nA command gate's `command` is run by Takt without any policy switch — Takt's default-deny `workflow_command_gates.custom_scripts` policy applies to gates declared in workflow YAML, not to these — so read the frontmatter of any check you obtain with `rulesync fetch` before generating. It is not unconditional in the other direction either: since Takt 0.62.0 command gates run after rule resolution and before the transition is applied, and a workflow rule with `command_gates: skip` applies its transition without running them. The body of a check that carries a `command` is not used. `workflow_overrides` is owned by the checks feature: it is rewritten from `.rulesync/checks/` on every generate, so a gate deleted there disappears from `config.yaml` too, while every other key of the file is preserved and the file is never deleted. When checks remain but none of them target Takt — every one names other tools — the block is retracted with a warning, whether an earlier generate or a hand edit put it there; that is what owning the key means, so author gates as checks rather than in `config.yaml`. A project with no `config.yaml` does not get one. Emptying `.rulesync/checks/` altogether is different: the feature has no source to generate from, so nothing runs and the gates already in `config.yaml` stay. Delete the block by hand in that case — a command gate left behind keeps running on every step it applies to (unless a rule skips it). On import, each gate becomes its own check file, named from the gate text or the command gate's `name`. A string gate is prose that applies anywhere, so it imports with `targets: [\"*\"]` like an Amp check; a command gate imports as `targets: [\"takt\"]`, since its body is empty and would generate an empty check for every other tool. A gate scoped to both a step and a persona becomes two checks, and a command gate carrying a field of the wrong type is left in `config.yaml` rather than imported. The default-deny `workflow_command_gates.custom_scripts` policy is **not** written here — Takt validates it against gates declared in workflow YAML, not against these, and it is authorable through the `takt` block of `.rulesync/permissions.*`, which owns the security policies. See the [Takt workflows docs](https://github.com/nrslib/takt/blob/main/docs/workflows.md).\n\nThe emitted Amp frontmatter is derived from the source as follows:\n\n| Amp field | Source |\n| ------------------ | -------------------------------------------------------- |\n| `name` | the source file basename without `.md` (required by Amp) |\n| `description` | `description` |\n| `severity-default` | `severity` |\n| `tools` | `tools` |\n\nThe frontmatter schema is loose, so extra Amp-specific keys survive a generate/import round-trip (except keys that collide with a rulesync tool-target name such as `cursor` — those are treated as tool-scoped sections and are not re-emitted). A tool-scoped section (e.g. `amp: { \"severity-default\": \"critical\" }`) overrides the canonical values for that tool — the tool-specific value takes precedence, and the section itself is not emitted (except `name`, which always comes from the file name). On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.\n\n> **Amp runner:** The supported way to run checks is Amp's official `@amp/review-checks` plugin (`amp plugins add @amp/review-checks`); the `amp review` command is deprecated, and existing checks in this format continue to apply. Amp also discovers subtree-scoped checks (e.g. `api/.agents/checks/`, applied to changed files in that directory and its children), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the [Amp review docs](https://ampcode.com/docs/review).\n\n> **Goose note (checks):** `goose review` discovers repository checks in `.agents/checks/*.md`, the same cross-tool directory the `amp` target writes (Goose's `tools` and `severity-default` fields exist for parity with Amp's), so the `goose` checks target is **global-only** and project scope is left to `amp`: `rulesync generate --targets goose --global` writes `~/.config/goose/checks/<name>.md`, which Goose reads before `~/.config/agents/checks/`. The frontmatter is Amp's (`name` from the file name, `description`, `severity-default` from `severity`, `tools`) plus Goose's `model` and `turn-limit` (a non-negative integer), which are authored in a `goose:` section; import puts them back into that section, so they do not reach other targets. See [`crates/goose/src/checks/mod.rs`](https://github.com/aaif-goose/goose/blob/main/crates/goose/src/checks/mod.rs).\n\n> **JetBrains AI Assistant note:** the `aiassistant` target does not generate checks. [Self-Review with AI](https://www.jetbrains.com/help/ai-assistant/ai-in-vcs-integration.html#define-guidelines-for-self-review) reads a Markdown guidelines file only from the path entered in **Settings | Tools | AI Assistant | Project Settings | Path to rules for AI Self-Review** ([project settings reference](https://www.jetbrains.com/help/ai-assistant/settings-reference-project-settings.html)). The setting has no default path, so AI Assistant would not pick up any file Rulesync wrote until each user configured it in the IDE. To reuse your review guidance, point that setting at a plain Markdown file you keep in the repository (a `.rulesync/checks/` source would also pass its YAML frontmatter to the reviewer as text).\n\n## `.rulesync/skills/*/SKILL.md`\n\nExample:\n\n```md\n---\nname: example-skill # skill name\ndescription: >- # skill description\n A sample skill that demonstrates the skill format\ntargets: [\"*\"] # * = all, or specific tools\n# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,\n# copilotcli, crush, cursor, zed, pi, omp, qwencode, vibe, grokcli, factorydroid, dsh, reasonix,\n# commandcode, and lettacode. Any of those tool sections can override it by setting their own `disable-model-invocation`\n# value below. devin also reads this root value (true maps onto a user-only\n# `triggers` list); it has no section key of the same name, but devin.triggers\n# overrides it. codexcli also reads this root value (true maps onto\n# `policy.allow_implicit_invocation: false` in the agents/openai.yaml sidecar);\n# codexcli.policy.allow_implicit_invocation overrides it.\ndisable-model-invocation: true\n# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,\n# copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid, dsh, reasonix, commandcode,\n# and lettacode.\n# Any of those tool sections can override it by setting their own `user-invocable` value below.\n# devin also reads this root value (false maps onto a model-only `triggers`\n# list); it has no section key of the same name, but devin.triggers overrides it.\n# antigravity-ide, antigravity-cli and antigravity-plugin also read this root value\n# (false maps onto `disable-slash-command: true`); their section's\n# `disable-slash-command` overrides it.\nuser-invocable: false\n# (optional) shared defaults for the three Agent Skills standard packaging fields.\n# Each applies to every tool that models the field, and any of those tool sections\n# can override it by setting the same key below (a section value replaces the root\n# value outright; the two are never merged). Targets that normalize the field —\n# the Agent Skills writers (agentsskills, hermesagent, agentsmd) flatten an object\n# `compatibility` to a string and stringify `metadata` values — apply the same\n# normalization to a root value as to a section value.\n# `license`: claudecode, opencode, mimocode, kilo, kiro, deepagents, copilot, copilotcli, crush,\n# pi, omp, replit, rovodev, factorydroid, agentsskills (plus hermesagent and agentsmd), vibe,\n# zcode, commandcode, grokcli.\nlicense: MIT\n# `compatibility`: the same tools minus copilot, copilotcli and zcode. A free-form string\n# per the Agent Skills spec (1–500 chars); the object form is also accepted.\ncompatibility: \"Requires git and jq\"\n# `metadata`: the same tools as `compatibility`, plus cursor, zcode, dsh, antigravity-ide,\n# antigravity-cli and antigravity-plugin (Antigravity CLI renders `metadata.icon`).\nmetadata:\n author: example-org\nclaudecode: # for claudecode-specific parameters\n model: sonnet # opus, sonnet, haiku, or any string\n when_to_use: When the user asks to review a PR # (optional) extra trigger context appended to description\n allowed-tools: # (optional) tools usable without asking; accepts a string or a list\n - \"Bash\"\n - \"Read\"\n - \"Write\"\n - \"Grep\"\n disallowed-tools: # (optional) removes these tools while the skill is active (string or list)\n - \"WebFetch\"\n effort: high # (optional) effort while active: low | medium | high | xhigh | max\n argument-hint: \"[pr-number]\" # (optional) autocomplete hint for expected arguments\n arguments: # (optional) named positional arguments for $name substitution (string or list)\n - \"pr_number\"\n context: fork # (optional) set to \"fork\" to run the skill in a forked subagent context\n agent: code-reviewer # (optional) subagent type to use when context: fork\n background: false # (optional, context: fork only) wait for the forked subagent in the invoking turn instead of backgrounding it (default true)\n shell: bash # (optional) shell for ! command blocks: bash (default) or powershell\n hooks: # (optional) hooks scoped to the skill's lifecycle (free-form per the Claude Code docs)\n PreToolUse:\n - matcher: \"Bash\"\n disable-model-invocation: true # (optional) disable model invocation for this skill\n user-invocable: false # (optional) hide from the / menu while keeping model access\n scheduled-task: true # (optional) emit to ~/.claude/scheduled-tasks/<name>/SKILL.md (global scope only) instead of .claude/skills/<name>/SKILL.md\n # paths (optional) limits auto-activation to matching globs. Accepts a\n # comma-separated string, e.g. paths: \"src/**/*.ts,test/**/*.ts\", or a list:\n paths:\n - \"src/**/*.ts\"\n - \"test/**/*.ts\"\n # Claude Code accepts the three Agent Skills standard fields below but acts on\n # none of them; they matter for claude.ai skill uploads, the Skills API, and\n # packaging with package_skill.py.\n license: Apache-2.0 # (optional) license covering the skill\n compatibility: Requires Node.js 22 or later # (optional) environment requirements, up to 500 characters\n metadata: # (optional) free-form map for your own tooling; a non-map value is dropped by Claude Code\n catalog: internal\ncodexcli: # for codexcli-specific parameters\n short-description: A brief user-facing description\n # The following sections are emitted to the agents/openai.yaml sidecar next to SKILL.md.\n # See https://developers.openai.com/codex/skills.md\n interface: # (optional) UI metadata\n display_name: Example Skill\n short_description: A brief user-facing description\n default_prompt: Do the thing\n policy: # (optional) invocation policy\n allow_implicit_invocation: false # only invoke explicitly via $skill\n dependencies: # (optional) tool dependencies\n tools:\n - type: mcp\n value: example\n description: Example MCP tool\npi: # for Pi Coding Agent-specific parameters (optional; Agent Skills standard)\n # Authored either as a canonical list or as the spec's space-delimited string;\n # emitted to SKILL.md as the string, and imported back as the list.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n disable-model-invocation: true # (optional) disable model invocation for this skill\n license: MIT # (optional)\n compatibility: \"Requires git and jq\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\nreplit: # for Replit Agent-specific parameters (optional; Agent Skills standard)\n # Authored either as a canonical list or as the spec's space-separated string;\n # emitted to SKILL.md as the string, and imported back as the list.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n license: MIT # (optional)\n compatibility: \"Requires git and docker\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\ndeepagents: # for deepagents-cli (dcode)-specific parameters (optional; Agent Skills standard)\n # Authored as a canonical list; emitted to SKILL.md as a space-delimited string\n # (e.g. \"Bash Read\") because dcode rejects a YAML list at runtime.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n license: MIT # (optional)\n compatibility: # (optional) free-form compatibility metadata\n deepagents-version: \">=0.1.0\"\n metadata: # (optional) free-form metadata\n author: rulesync\nopencode: # for OpenCode-specific parameters (optional)\n license: MIT # (optional)\n compatibility: # (optional) free-form compatibility metadata\n opencode-version: \">=1.16.0\"\n metadata: # (optional) free-form metadata\n author: rulesync\n allowed-tools: # (optional) Anthropic-spec passthrough; OpenCode ignores unknown fields\n - \"Bash\"\n - \"Read\"\nkilo: # for Kilo Code-specific parameters (optional)\n license: MIT # (optional)\n compatibility: \"Requires git\" # (optional) free-form string (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\n allowed-tools: # (optional) backward-compat passthrough; not part of Kilo's official SKILL.md frontmatter\n - \"Bash\"\n - \"Read\"\nkiro: # for Kiro-specific parameters (optional; project .kiro/skills/, global ~/.kiro/skills/)\n license: MIT # (optional)\n compatibility: \"Requires network access\" # (optional) free-form string (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\n # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and\n # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and\n # `description` are the exception: they have canonical homes at the top level.\nkimi-code: # for Kimi Code-specific parameters (optional; project/global .kimi-code/skills/)\n type: inline # (optional) prompt, inline, or flow\n whenToUse: \"When reviewing pull requests\" # (optional) model invocation hint\n disableModelInvocation: false # (optional) prevent automatic model invocation\n arguments: [\"pull_request\"] # (optional) named arguments, also accepts a whitespace-separated string\nagentsskills: # for the Agent Skills standard target (optional; supports project + global ~/.agents/skills/)\n # `license`, `compatibility` and `metadata` fall back to the root-level values above\n # when omitted here; a value set here wins for this target only.\n license: MIT # (optional)\n compatibility: \"Requires Python 3.14+ and uv\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata (spec-recommended place for skill versioning)\n version: \"1.0.0\"\n allowed-tools: \"shell\" # (optional, experimental) space-separated string or list\namp: # for Amp-specific parameters (optional; project .agents/skills/, global ~/.config/agents/skills/)\n # Amp reads the open Agent Skills standard and documents no frontmatter field beyond\n # `name`/`description`, so this section exists only to carry keys a hand-written SKILL.md adds:\n # they are imported into it and written back to the top level of the generated file instead of\n # being erased on regeneration. `name` and `description` are the exception — they have canonical\n # homes at the top level and a section value of either is ignored.\ncopilot: # for GitHub Copilot-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)\n license: MIT # (optional)\n allowed-tools: \"shell\" # (optional) tools pre-approved without per-use confirmation\n argument-hint: \"[message]\" # (optional) hint shown for the skill's expected arguments\n user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME\n disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own\n context: fork # (optional, experimental) run the skill in a forked session (VS Code 1.118+)\n # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and\n # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and\n # `description` are the exception: they have canonical homes at the top level. Like the modeled\n # fields below, such a key rides one section only, so the shared-path caveat that follows applies\n # to it too.\n # `copilot` and `copilotcli` write the same SKILL.md path at both scopes, so with both targets\n # enabled the one generated last wins — and that is the order the targets are listed in, so which\n # section decides the file is not fixed. Set the value in both sections (or, for the two invocation\n # gates, in the shared top-level fields) whenever you generate for both. `context` has no\n # `copilotcli` counterpart, so it survives only when `copilot` is generated last.\ncopilotcli: # for GitHub Copilot CLI-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)\n license: MIT # (optional)\n allowed-tools: \"shell\" # (optional) tools pre-approved without per-use confirmation\n argument-hint: \"[message]\" # (optional) hint shown for the skill's expected arguments\n user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME\n disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own\n # As in the `copilot` section, any other frontmatter key found in a hand-written SKILL.md is\n # imported here and written back out.\nrovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard)\n allowed-tools: \"grep bash\" # (optional) space-separated string (a YAML list is also accepted)\n license: MIT # (optional)\n compatibility: \"Requires Python 3.14+ and uv\" # (optional) free-form string (object form also accepted)\n metadata: # (optional) free-form metadata\n author: rulesync\nzed: # for Zed-specific parameters (optional)\n disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill\ncursor: # for Cursor-specific parameters (optional)\n paths: # (optional) glob patterns (string or list) scoping the skill to matching files\n - \"src/**/*.ts\"\n disable-model-invocation: true # (optional) only include the skill when invoked via /skill-name\n user-invocable: false # (optional) hide from / autocomplete and typed /skill-name, keep model access\n icon: rocket # (optional) Custom Modes badge icon (code, terminal, bug, git-branch, book-open, beaker, shield, rocket); unknown values fall back to Cursor's default badge\n color: purple # (optional) Custom Modes badge color (default, green, cyan, blue, purple, magenta, orange, yellow, red, brand)\n # On import, the legacy Cursor `globs` field is read as a fallback for `paths` (and dropped when `paths` is also set); generate always writes `paths`.\n # A `.cursor/skills/` subfolder without its own SKILL.md (a category folder grouping skills) is skipped on import and left in place by `generate --delete`; skills nested inside it are not imported.\n metadata: # (optional) free-form metadata\n author: rulesync\nfactorydroid: # for Factory Droid-specific parameters (optional)\n disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill\n user-invocable: false # (optional) hide from the slash-command menu, keep model access\n enabled: false # (optional, default true) keep the skill on disk but stop Droid loading it\n allowed-tools: \"Read Execute\" # (optional) tools the skill is designed to use (string or list)\n # Droid documents the four packaging fields below without a type and never validates\n # them, so rulesync carries whatever value they hold through in both directions.\n license: MIT # (optional) license metadata for shared skills\n compatibility: droid # (optional) compatibility metadata for catalogs, plugins, or team tooling\n metadata: # (optional) structured metadata for your own tooling\n owner: platform-team\n version: \"1.0.0\" # (optional) version string for shared or packaged skills — quote it, since\n # an unquoted 1.0 is a YAML number and is emitted back as `version: 1`\n # `name` and `description` are the exception: the top-level values always win over a\n # value of the same key inside this section.\n # As in the `kiro` section, any other frontmatter key found in a hand-written SKILL.md is\n # imported here and written back out.\ntakt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\ndevin: # for Devin-specific parameters (optional; project .devin/skills/, global ~/.config/devin/skills/)\n argument-hint: \"[environment]\" # (optional) hint shown after the slash-command name\n model: \"fast\" # (optional) model override while the skill runs\n subagent: true # (optional) run the skill in a subagent (string or boolean per Devin's docs)\n agent: \"deployer\" # (optional) named agent profile to run the skill with\n allowed-tools: # (optional) tools available while the skill runs (string or list)\n - \"Bash(git status:*)\"\n permissions: {} # (optional) auto-approval rules applied while the skill runs (load-bearing since Devin CLI v3000.1.23)\n triggers: [\"user\"] # (optional) invocation gating; omitted = user + model. The shared disable-model-invocation / user-invocable flags map onto this when unset.\nqwencode: # for Qwen Code-specific parameters (optional; project .qwen/skills/, global ~/.qwen/skills/)\n priority: 10 # (optional) higher values appear earlier in /skills listings\n paths: # (optional) glob patterns gating model discovery to matching files (a scalar is coerced to the array Qwen Code requires)\n - \"src/**/*.ts\"\n user-invocable: false # (optional) hide from slash-command invocation, keep model access\n disable-model-invocation: true # (optional) hide from the model but allow direct user invocation\n allowedTools: # (optional) permissions.allow-syntax rules auto-approved while the skill is active\n - \"Shell(git status:*)\"\n model: \"fast\" # (optional) model override while the skill runs (model id, fast, authType:modelId, inherit)\n hooks: {} # (optional) session-scoped hooks registered while the skill runs (settings.json shape)\n when_to_use: \"Use when deploying\" # (optional) invocation guidance surfaced in the SkillTool description\n argument-hint: \"[environment]\" # (optional) hint shown after the slash-command name in completion\ngrokcli: # for Grok CLI-specific parameters (optional)\n user-invocable: false # (optional) hide from the skill tool, keep model access\n disable-model-invocation: true # (optional) block auto-invocation, keep the slash command\n paths: [\"src/api/**\", \"*.sql\"] # (optional) gitignore globs; the skill stays hidden until a tool touches a matching file (a comma-separated string is also accepted)\n when-to-use: \"When the user asks to deploy\" # (optional) trigger phrases kept separate from description (Grok also reads `when_to_use`; either spelling is written as authored)\n allowed-tools: [\"Bash\", \"Read\"] # (optional) tools the skill uses, a list or a comma- or space-separated string\n argument-hint: \"[environment]\" # (optional) hint shown in the slash-command autocomplete\n model: \"grok-code-fast-1\" # (optional) model override for running the skill\n effort: \"high\" # (optional) reasoning-effort override\n metadata: # (optional) overrides the root value outright; Grok displays `author` and `short-description`\n author: example-org\n # Any other key (e.g. `origin`) is written verbatim, and on import every key besides\n # name/description comes back into this section.\ndsh: # for DeepSeek Harness-specific parameters (optional; project .dsh/skills/, global ~/.dsh/skills/)\n whenToUse: \"When the user asks to review a PR\" # (optional) extra trigger-timing context\n metadata: # (optional) free-form metadata; overrides the root value outright\n author: example-org\n user-invocable: false # (optional) hide from user invocation, keep model access\n disable-model-invocation: true # (optional) block auto-invocation\nreasonix: # for Reasonix-specific parameters (optional; project .reasonix/skills/, global ~/.reasonix/skills/)\n user-invocable: false # (optional, v2) hide from the slash surface, keep model access\n disable-model-invocation: true # (optional) hide from the model; also written as `invocation: manual` for the v1 line\n invocation: manual # (optional) Reasonix's catalog switch (auto | manual); always manual when model invocation is disabled\nvibe: # for Vibe Code-specific parameters (optional)\n user-invocable: false # (optional) hide from slash-command invocation, keep model access\n disable-model-invocation: true # (optional, Vibe v2.25.5+) explicit-only: keep the slash command, block model-initiated loading\n allowed-tools: \"Bash Read\" # (optional) space-delimited or list of allowed tool names\ncline: # for Cline-specific parameters (optional; project/global .cline/skills/; keys are written into SKILL.md verbatim)\n disabled: true # (optional) Cline's own on/off toggle: hides the skill until it is re-enabled in Cline (`enabled: false` is read the same way)\nantigravity-cli: # for Antigravity CLI-specific parameters (optional; `antigravity-ide` takes the same keys. Both targets write the shared project `.agents/skills/`, so there each reads `antigravity-ide` then `antigravity-cli` merged, the CLI winning; globally each reads its own. `antigravity-plugin` reads the `antigravity-ide` section)\n disable-slash-command: true # (optional, CLI v1.1.12+) hide from the / menu, keep model access; overrides the root user-invocable\n metadata: # (optional) free-form metadata; overrides the root value outright\n icon: \"📦\" # (optional, CLI v1.1.20+) emoji shown in the /skills catalog and slash-command autocompletion\n---\n\nThis is the skill body content.\n\nYou can provide instructions, context, or any information that helps the AI agent understand and execute this skill effectively.\n\nThe skill can include:\n\n- Step-by-step instructions\n- Code examples\n- Best practices\n- Any relevant context\n\nSkills are directory-based and can include additional files alongside SKILL.md.\n\nWhen `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `\"*\"`. Claude Code reads scheduled tasks only from the user config directory, so the skill is written only by `rulesync generate --global`, to `~/.claude/scheduled-tasks/<name>/SKILL.md` (Rulesync always writes under `~/.claude/`; it does not follow `CLAUDE_CONFIG_DIR`). A project-scope generate warns and skips it instead of writing a copy under the project's `.claude/scheduled-tasks/` that Claude Code would never run; a project-scope generate with `--delete` removes a copy an earlier version left there. Import still reads both locations.\n```\n\n> **Supporting-file note:** every file beside `SKILL.md` in a skill directory is copied **byte for byte**, to whichever tool root the skill is generated into. Most of them are user assets — images, archives, fixtures whose CRLF line endings or missing trailing newline are deliberate — so unlike `SKILL.md`, whose body and frontmatter Rulesync composes, they get no UTF-8 round-trip, no line-ending normalization and no trailing newline appended. Change detection compares them byte for byte too, so a supporting file written by an older Rulesync (which normalized text files) or edited in place by a formatter is rewritten from the source on the next generate. A supporting file that is executable in `.rulesync/skills/<name>/` (any of the `0o111` bits set) is written executable too, with its permission bits masked to `0755` (group and world write bits are dropped, as are setuid, setgid and sticky), so a `scripts/run.sh` an agent is told to run stays runnable; a generate that finds the directory unchanged still restores the bit if something stripped it. Files without an executable bit keep the mode they already have, or the process default when newly created. A symbolic link standing where the copy should be is never chmod-ed. On Windows there is no mode to carry, and none is applied. The one exception is a supporting file Rulesync composes itself rather than carries through — Codex CLI's `agents/openai.yaml` — which is compared by parsed content so that re-indenting it does not report a change on every generate. Dot-prefixed entries count as supporting files too — the specification says a skill directory \"may contain any files and directories beyond the required `SKILL.md`\", and a hidden `.env.example` or `.config/` is content the skill needs. Some entries are never carried, whether they are reached by their own name or through a symbolic link that renames them:\n>\n> - `.git`, `.hg` and `.svn` — a nested repository, whose tracked files are copied but whose history is not. Rulesync warns when it skips a top-level `.git`.\n> - `.DS_Store` — the macOS Finder's index.\n> - Credential stores and credential-shaped files: anything under `.ssh/`, `.aws/` or `.gnupg/`, plus `.npmrc`, `.netrc`, `.git-credentials`, `.pgpass`, `.pypirc`, `.htpasswd`, `.dockercfg`, `.envrc`, `.docker/config.json`, `.kube/config`, `.config/gh/hosts.yml`, `.config/gcloud/credentials.db`, `.gem/credentials`, `gcloud/application_default_credentials.json`, `.codex/auth.json` and `.gemini/oauth_creds.json`. A credential name counts wherever it appears in the path, not only as the last segment: `.env/production` and `.netrc/machine` hold what the files of those names hold, so a directory given a credential file's name is refused with everything under it. Carrying a secret would copy it into every enabled tool root, multiplying the places it can be committed from. A link that leaves the skill directory to reach a `.config/`, `.local/`, `.azure/`, `.m2/`, `.terraform.d/`, `.docker/`, `.kube/` or `Keychains/` tree is refused entirely — those are the per-application directories of a home directory, where naming each credential file is a list always one release behind — while a `.config/` the skill ships itself is carried as ordinary content. The exemption is for a sibling: a link that goes no higher than the skill directory's own parent, so a global skill under `~/.config/agents/skills/` still shares `../_shared` there while the same skill reaching `~/.config/gcloud/` is refused like any other; a tool home such as `~/.claude/` is deliberately not on the list, because that is where the global skills this feature exists to share are kept.\n> - `.env` and every `.env.<suffix>` spelling except the template ones — the last piece of the name decides, so `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.defaults` and compound spellings such as `.env.local.example` are carried, being the case this support exists for, while `.env.local` and `.env.production` hold real values.\n> - Build, cache and virtual-environment trees: `.cache`, `.venv`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.gradle`, `.next`, `.nuxt`, `.turbo`, `.parcel-cache`, `.nyc_output` and `.terraform`. These are regenerated from the skill's own sources, and copying a `.venv` into every enabled tool root would multiply thousands of files that are not skill content.\n>\n> Names are compared case-insensitively, so `.SSH/` is excluded on the platforms that treat it as `.ssh/`, and trailing dots and spaces are trimmed first, because Windows drops them when the file lands (`.env ` becomes `.env` there). Leaving out a credential-shaped entry is reported in a warning, and so is an entry whose own name says nothing about it — a link named `assets` pointing into a `.cache/` tree; the ordinary exclusions, where the name in the skill directory is the excluded one — `.DS_Store`, build and cache trees — stay quiet.\n>\n> A skill directory is also walked within bounds, because a symbolic link in it may point anywhere: at most **12 directories deep**, at most **10,000 files**, at most **10,000 directories**, at most **200,000 entries looked at**, and at most **100MB** in total per directory, and each real directory is visited once so that a link pointing back at an ancestor collapses instead of multiplying the walk. Because only one route to a directory is walked, the route is chosen rather than left to the walk order: everything reachable without crossing a symbolic link first, then everything one link away, and so on — so a file keeps the path its `SKILL.md` refers to. When a route that the hidden-entry rule below then refuses is the one that reached a tree, the directory is walked once more with the hidden routes left out, so a tree that a fully named path also reaches is still carried under that name. Reaching any of those limits leaves files out, so it is always reported in a warning rather than silently truncating the skill; so is an entry that cannot be read at all. A route that passes through `/proc`, `/sys` or `/dev` is refused at every hop rather than only at its destination: `/proc/<pid>/fd/N` and `exe` resolve to whatever a running process holds open, so a check of the destination alone would carry another program's private key as though it were an ordinary file. Each hop's parent directory is resolved as well, since a link named `fds` pointing at `/proc/self/fd` leaves no `/proc` in the path `fds/3` to check; the hops a chain costs count against the entry limit above, so a directory of long chains cannot buy more work than a directory of files.\n\n> **`.agents/skills/` ownership note:** `.agents/skills/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the [Agent Skills](https://agentskills.io/specification) project location, which the native `agentsskills` target writes. Several targets write there — `agentsskills`, `agentsmd`, `aiassistant`, `codexcli`, `amp`, `zed`, `replit` and both Antigravity targets — because they all implement the same convention. Each native target writes its own documented frontmatter, so enabling more than one and reordering `--targets` can change which optional keys end up in the file; that is inherent to several tools sharing one path and is not specific to any of them.\n\n> The **simulated** `agentsmd` writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare `name`/`description` pair and silently drop `license`, `compatibility`, `metadata` and `allowed-tools`. It now emits exactly what `agentsskills` emits, so a simulated writer can never degrade the file a native target owns.\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes skills into a plugin bundle's `skills/<name>/SKILL.md` in the same format as `.augment/skills/` (project scope only), since Auggie reads plugin skills the same way; import reads only the bundle's own `skills/`, not the shared `.agents/skills/` root. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Cross-root duplicate note:** Several targets discover skills in more than one root — a shared Agent Skills root beside the tool-specific one (`junie`, `vibe`, `kimi-code`, `rovodev`, `augmentcode`, `warp`), nested `.claude/skills/` directories (`claudecode`), or roots the tool's own config points at (`opencode`). The roots are scanned in precedence order and the first one to claim a skill name keeps it; the multi-root subagent targets follow the same rule, and for targets whose native format aggregates many subagents into one file (Roo's `.roomodes`) the same de-duplication is applied to the paths the entries fan out to. The comparison is **case-insensitive** (and Unicode normalizing), because every imported skill is written back into a single `.rulesync/skills/` tree, where `.junie/skills/my-skill` and `.agents/skills/My-Skill` are one directory on macOS and Windows — comparing the names exactly would let both through, and the shared copy, written last, would overwrite the tool-specific one, inverting the precedence the roots were ordered by. A collision that differs only in case is reported with a warning naming the ignored copy; an exact repeat stays quiet, being the ordinary overlay. Kimi Code is the exception on both counts: it identifies a skill or subagent by its frontmatter `name` folded to lower case, so two spellings are already one name upstream and the duplicate is resolved silently. **Trade-off:** the rule applies on every platform, so on a case-sensitive filesystem — where the two really are separate skills — `rulesync import` and `rulesync convert` drop the lower-precedence one instead of importing it under its own name. Rename one of them if you need both — and if the dropped copy lives in a shared `.agents/skills/` root, import `agentsskills` in the same run, so the skill still reaches `.rulesync/` and a later `agentsskills` generate does not prune it as an orphan. Slash commands are not covered by this rule yet: `augmentcode` reads both `.augment/commands/` and the shared `.agents/commands/`, but their names are still compared exactly, so `deploy.md` and `Deploy.md` are both imported and collide in `.rulesync/commands/` on a case-insensitive filesystem ([issue #2741](https://github.com/dyoshikawa/rulesync/issues/2741)).\n\n> **Claude Code nested skills note:** Claude Code v2.1.178+ also loads skills from **nested** `.claude/skills/` directories below the working directory (a skill in `apps/web/.claude/skills/` becomes available when working on files there, and a name clash with a root skill keeps both under a directory-qualified name like `apps/web:deploy`). `rulesync import --targets claudecode --features skills` discovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nested `AGENTS.md` scan; symlinks not followed) so an existing nested skill is no longer invisible. A nested directory whose reported path does not resolve inside the project is skipped with a warning rather than followed: a glob rewrites a backslash in a directory name into a path separator, so the path it reports may belong to nothing on disk, or lead out of the project — through a `..` the name also carries, or through a symlink the scan itself never follows. A reported path that stays inside the project but resolves onto something that is not a `.claude/skills` directory of its own — a dependency or build tree, or the project root itself — is skipped the same way, since reading it as a skills directory would offer every directory under it as a skill. The project-root `.claude/skills/` itself is never read as a nested root, even when it is a symlink to a directory elsewhere in the project: the directory it resolves to belongs to the root import, and the nested scan leaves it alone. On a name clash the root skill wins the import — rulesync's flat skill namespace cannot express the qualified variant. Because generation stays targeted at the project-root `.claude/skills/`, a nested skill's location-based scoping would otherwise be lost, so the import derives it: a skill found in `apps/web/.claude/skills/` gets `claudecode.paths: [\"apps/web/**\"]` written for it. Glob metacharacters in the directory names are escaped, so a Next.js-style `app/[slug]/.claude/skills/` still derives a literal match. A `paths` value the skill already declares is kept as-is — Claude Code does not document whether a nested skill's `paths` resolves against the project root or its own directory, so rulesync does not rewrite the author's glob, which means a declared value narrower than the subtree (`src/**`) is re-anchored at the project root once the skill moves there; write it subtree-qualified (`apps/web/src/**`) if that matters. Root-discovered skills get nothing added, and the derived value lands in the `claudecode:` block only — other targets with their own `paths` field (`cursor:`, `qwencode:`) are untouched. To scope a skill's _activation_ to a subtree yourself, write the `paths` frontmatter, or run a separate generate with `--output-roots <subdir>` for physical co-location.\n\n> **Claude Code `argument-hint` and import leniency note:** Claude Code's docs write `argument-hint: [issue-number]` unquoted, which YAML reads as a one-element list. On **import** (and `rulesync convert --from claudecode`), a list `argument-hint` in a `.claude/skills/*/SKILL.md` or `.claude/commands/*.md` is accepted and joined back into the bracketed string, one placeholder per entry (`[issue-number]`; `[filename, format]` becomes `[filename] [format]`; an empty list is dropped), so `claudecode.argument-hint` in `.rulesync/` is always a string and is emitted quoted on generate. The unquoted multi-placeholder form `argument-hint: [filename] [format]` is not valid YAML at all; quote it (`\"[filename] [format]\"`). Because Claude Code keeps loading the other skills when one `SKILL.md` is broken, a Claude Code skill that fails to load on import or convert (unparseable YAML, invalid frontmatter) is skipped with a warning, and the remaining skills and every later feature (hooks, permissions, …) still import. Commands are not covered by this leniency: an invalid `.claude/commands/*.md` still aborts the import.\n\n> **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section.\n\n> **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, or the root `disable-model-invocation` is `true`, Rulesync emits `.agents/skills/<name>/agents/openai.yaml` and reads it back on import. The root `disable-model-invocation: true` maps onto `policy.allow_implicit_invocation: false` (Codex then only runs the skill when invoked explicitly via `$skill`); an explicit `codexcli.policy.allow_implicit_invocation` takes precedence over the root flag, and a root `false` or absent flag adds nothing (Codex already defaults to `true`). On import, `policy.allow_implicit_invocation` is kept under `codexcli.policy` rather than promoted to the root `disable-model-invocation`, like every other tool's importer keeps its invocation flags in its own section; this regenerates the same sidecar without changing the output of other targets. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md).\n\n> **Takt-driven Codex note:** Rulesync's `codexcli` skills land in `.agents/skills/` (project) and `~/.agents/skills/` (global), but a Takt workflow driving Codex does **not** inherit repository or user skills from there by default — upstream's wording is \"TAKT workflows do not inherit repository or user Codex Skills by default\" — so skills you generated will not reach a Takt-driven Codex run unless you turn inheritance on. (`takt exec` is the documented exception: each scope defaults to inheritance when it is not explicitly configured.) The setting is `provider_options.codex.skills.repo` for the project tree and `.user` for the global one, added in Takt 0.53.0. **Where it goes depends on your Takt version.** Up to 0.55.x, and on any later version whenever `runtime.yaml` is inactive (a file carrying only `version: 1` counts as inactive and leaves the legacy resolution in place), it belongs in `provider_options` in `.takt/config.yaml` — which is also where a `takt` block in `.rulesync/permissions.*` writes it. From 0.56.0, `runtime.yaml` owns provider configuration, and while it is active **any** legacy provider setting in `config.yaml` — `provider_options` included — stops Takt with `Mixed provider configuration detected` before it runs an agent. Takt generates `~/.takt/runtime.yaml` active on first launch in a fresh environment, so a new install is in runtime mode by default; there, set the flag in `runtime.yaml` under the `options` of a profile whose provider is Codex, and keep `provider_options` out of `config.yaml`. Mind the shape when you move it: a profile's `options` is a **flat bag applying to that profile's own provider**, so the `codex` segment is dropped — `options: { skills: { repo: true } }`, not `options: { codex: { skills: { repo: true } } }`. The nested spelling is not a schema error; it is simply never read, so inheritance stays off while the config looks right. Takt 0.57.0 adds a workflow-side alternative to writing `provider_options` inline: a workflow, step, or parallel sub-step can declare `capabilities: enable-skills`, a bundled preset covering the Codex repo and user skills. Takt 0.55.0 made the same default change for Claude providers (`provider_options.claude.skills.enabled`, plus `--disable-slash-commands` on CLI-backed ones), so Rulesync-generated Claude Code skills and slash commands are off in Takt-driven sessions unless re-enabled the same way. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md) and [CHANGELOG](https://github.com/nrslib/takt/blob/main/CHANGELOG.md).\n\n> **Reasonix note:** Reasonix discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.reasonix/skills/` (project) / `~/.reasonix/skills/` (global, via `--global`). Rulesync emits the portable `name`/`description` frontmatter; the schema is loose, so an imported `SKILL.md` carrying extra keys still parses, but only the modeled keys are carried into the canonical skill (others, such as `argument-hint`, are dropped on import). The invocation flags are modeled too: the root-level `disable-model-invocation` / `user-invocable` defaults (overridable per tool under a `reasonix:` section) are written as Reasonix's own `disable-model-invocation` / `user-invocable` keys, which the v2 line reads (`internal/ext/skill/invocation_flags.go`, since v2.28.0). The legacy v1 line only knows `invocation: manual`, which hides the skill from the model's catalog while it stays callable by name — weaker than the v2 flag, which also refuses model calls. A skill whose model invocation is disabled therefore also gets `invocation: manual`, the closest v1 equivalent. `reasonix.invocation` (`auto` / `manual`) authors that catalog switch directly and is written verbatim when set, unless model invocation is disabled, which always writes `manual`. On import, the native flags land in the `reasonix:` section (Reasonix's `yes`/`no`, `on`/`off` and `1`/`0` spellings are read as booleans; like Reasonix, an unreadable `disable-model-invocation` fails closed and is read as `true`, while an unreadable `user-invocable` is ignored), and an `invocation` is kept as `reasonix.invocation` — except the `manual` that accompanies `disable-model-invocation: true`, which follows from the flag. See [invocation_flags.go](https://github.com/esengine/DeepSeek-Reasonix/blob/v2.31.0/internal/ext/skill/invocation_flags.go) and the v1 [skill.go](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.39.8/internal/skill/skill.go). See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md).\n\n> **Meta Muse Code note:** Muse Code discovers Agent Skills (`<skill-id>/SKILL.md`) under `.agents/skills/` (project) and under `$XDG_CONFIG_HOME/muse/skills` plus `~/.agents/skills` (user). Rulesync emits the shared `.agents/skills/` directory in project mode and only the XDG-default `~/.config/muse/skills` in global mode (via `--global`), so a skill is written exactly once. Muse Code's compatibility scans of repo-local `.codex/skills` and `.claude/skills` belong to other tools and are not emitted for `musecode`. Only the portable `name`/`description` frontmatter pair is modeled; the schema is loose, so extra keys on an imported `SKILL.md` survive the round-trip. See the [Muse Code extending docs](https://dev.meta.ai/docs/muse-code/extending.md).\n\n> **Zed note:** Zed discovers skills under `.agents/skills/` (project) and `~/.agents/skills/` (global) and applies rules of its own when it loads one, which rulesync reports as warnings at generate time rather than discovering them in Zed's UI: a `name` must be 1–64 characters of lowercase letters, digits and hyphens, with no leading, trailing or consecutive hyphens — a skill whose name breaks that rule \"fail[s] to load\" in Zed — and a `description` should be at most 1024 characters, past which Zed still loads the skill but warns. The skill is written either way, since the canonical skill is shared with every other target; fix the frontmatter in `.rulesync/skills/<name>/SKILL.md` to clear the warning. Zed also caps the combined size of every installed skill's name and description at 50KB and drops what does not fit from its catalog; that budget spans skills rulesync did not write, so it is not checked. Zed's own `disable-model-invocation` is authored in the `zed:` block. See the [Zed skills docs](https://zed.dev/docs/ai/skills).\n\n> **Hermes Agent note:** Hermes skills are global-only under `~/.hermes/skills/<name>/SKILL.md`. Standard Agent Skills fields (`license`, `compatibility`, and `allowed-tools`) round-trip through `agentsskills` and are normalized to the Agent Skills spec shapes described above (so `allowed-tools` is written and imported the same way as for `agentsskills`); Hermes-native fields such as `version`, `author`, `platforms`, `environments`, `required_environment_variables`, `required_credential_files`, and `metadata.hermes` round-trip through `hermesagent`. Canonical `name` and `description` always own those two frontmatter keys.\n\n> **Warp skills note:** Warp discovers skills under `.warp/skills/` (project) and `~/.warp/skills/` (global), plus the cross-tool `.agents/skills/` root at either scope — the location Warp's docs recommend. Rulesync generates to `.warp/skills/` only; the `.agents/skills/` root is import-only (declared under `importOnlySkillRoots`, like `junie` / `vibe` / `kimi-code`), scanned after `.warp/skills/` under the cross-root duplicate rule above, and never touched by `warp`-target orphan deletion — in project or global mode. Warp also lists other tools' skill roots (`.claude/skills/`, `.codex/skills/`, …) and discovers skills in subdirectories of a repository; those are not imported as `warp` skills — import the owning target instead. See the [Warp skills docs](https://docs.warp.dev/agents/capabilities/skills/).\n\n> **Kimi Code note:** Kimi Code discovers skills under `.kimi-code/skills/` (project) and `~/.kimi-code/skills/` (global), plus the shared `.agents/skills/` root at either scope. Rulesync generates the recommended directory layout (`<name>/SKILL.md`) and imports both that layout and flat `<name>.md` skills; for flat files, a missing `name` comes from the filename and a missing `description` falls back to the first non-empty body line (up to 240 characters), matching Kimi. Imported skills are written to `.rulesync/skills/<logical-name>/SKILL.md`, using the normalized logical frontmatter name rather than the source directory or filename. Duplicate precedence follows Kimi's case-insensitive logical frontmatter `name`: the Kimi-specific root takes precedence over `.agents/skills/`, and a directory skill takes precedence over a same-named flat file within one root. Shared roots are import-only and are never removed by Kimi-target orphan deletion. Besides `name`/`description`, Rulesync maps Kimi's `type`, `whenToUse`, `disableModelInvocation`, and `arguments` frontmatter through the `kimi-code:` block and preserves supporting files beside directory-layout `SKILL.md`. The shared top-level `disable-model-invocation` value supplies the Kimi flag unless the tool-specific block overrides it. See the [Kimi Code Agent Skills docs](https://moonshotai.github.io/kimi-code/en/customization/skills.html). The `kimi-code-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **ZCode note:** ZCode discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) and invokes them with `$`. The documented location is the user one, `~/.zcode/skills/`; the workspace scope its import dialog offers is taken to be served from the project's own `.zcode/skills/`, so rulesync writes `.zcode/skills/<name>/SKILL.md` in project mode and `~/.zcode/skills/<name>/SKILL.md` with `--global`. That project path is inferred from the import dialog and from ZCode's other workspace assets, not documented. ZCode's [Skill `SKILL.md` field reference](https://zcode.z.ai/en/docs/plugin) allowlists exactly five frontmatter keys — required `name`/`description` plus optional `when_to_use` (extra trigger-timing context), `license`, and `metadata` (an object that may carry extras such as `author`/`version`) — and states that fields outside that list \"are ignored and do not affect loading\". Rulesync models all five: `license` and `metadata` fall back to the root-level Agent Skills packaging defaults, and `when_to_use` is authored in the `zcode:` block. There is no documented `compatibility` field, so the canonical `compatibility` is not written for `zcode`. The schema is loose, so an imported `SKILL.md` carrying extra keys still parses, but only those five are carried into the canonical skill. ZCode rejects a `description` longer than 1024 characters and truncates a body past 100KB when it loads the skill; rulesync does not enforce either limit, since the canonical skill is shared with every other target. See the [ZCode skills docs](https://zcode.z.ai/en/docs/skill). The `zcode-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **IBM Bob note:** IBM Bob discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.bob/skills/` (project) and `~/.bob/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`; any additional keys round-trip through the `bob` frontmatter block. See the [skills docs](https://bob.ibm.com/docs/ide/features/skills).\n\n> **Tabnine CLI note:** Tabnine CLI discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.tabnine/agent/skills/` (project) and `~/.tabnine/agent/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`; any additional keys round-trip through the `tabnine` frontmatter block. See the [agent skills docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/agent-skills).\n\n> **Snowflake Cortex Code note:** Snowflake Cortex Code discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.cortex/skills/` (project) and `~/.snowflake/cortex/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`, plus an optional `tools` allowlist; any additional keys round-trip through the `cortexcode` frontmatter block. (Cortex Code also falls back to `.claude/skills/`, which the `claudecode` target already covers.) See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#skills).\n\n> **Codewhale note:** Codewhale discovers directory-layout skills (`<name>/SKILL.md`) under `.codewhale/skills/` (project) and `~/.codewhale/skills/` (global, via `--global`). `name` and `description` are written from the canonical fields, and any Codewhale-specific frontmatter (for example `invocation`) is carried through the `codewhale` section of the rulesync skill.\n\n> **CodeBuddy Code note:** CodeBuddy Code discovers directory-layout skills (`<name>/SKILL.md`) under `.codebuddy/skills/` (project) and `~/.codebuddy/skills/` (global, via `--global`). Rulesync writes `name` and `description` from the canonical skill (same-named keys in the `codebuddy:` block are ignored), plus the shared `disable-model-invocation` / `user-invocable` defaults; the other documented keys (`allowed-tools`, `context`, `agent`, `model`, `hooks`) and any extra ones go in the `codebuddy:` block. CodeBuddy treats every SKILL.md key as optional, so on import a missing `name` falls back to the directory name and a missing `description` to an empty string. See the [CodeBuddy Code skills docs](https://www.codebuddy.ai/docs/cli/skills).\n\n> **Continue note:** Continue discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.continue/skills/` (project) and `~/.continue/skills/` (global, via `--global`). The `SKILL.md` frontmatter requires `name` (which must match the directory name) and `description`; any additional keys round-trip through the `continue` frontmatter block, and supporting files next to `SKILL.md` are carried along. (Continue also falls back to `.claude/skills/`, which the `claudecode` target already covers.) Continue has no doc page for skills yet; the [skill loader source](https://github.com/continuedev/continue/blob/5522c6f44ca0ac3528b37244818fbfa39b5af470/extensions/cli/src/util/loadMarkdownSkills.ts) is the reference.\n\n> **Command Code note:** Command Code discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.commandcode/skills/` (project) and `~/.commandcode/skills/` (global, via `--global`). The `SKILL.md` frontmatter requires `name` (matching the directory name) and `description`; the documented optional keys (`license`, `compatibility`, `metadata`, `allowed-tools`, `disallowed-tools`, `argument-hint`, `when_to_use`, `disable-model-invocation`, `user-invocable`, `arguments`, `model`, `effort`) and any newer key round-trip through the `commandcode` frontmatter block, and supporting files next to `SKILL.md` are carried along. The Agent Skills packaging trio (`license`, `compatibility`, `metadata`) and the two invocation gates (`disable-model-invocation`, `user-invocable`) also fall back to the root-level rulesync value when the `commandcode` block omits them; a value in the block wins. See the [skills docs](https://commandcode.ai/docs/skills).\n\n> **Qoder note:** Qoder reads Agent Skills directories (`<name>/SKILL.md`, with the required `name` and `description` frontmatter) from `.qoder/skills/` (project) and `~/.qoder/skills/` (global, via `--global`); the Qoder IDE and the CLI read the same directories, and on a name clash the user-level skill wins. Rulesync writes them with the same Agent Skills frontmatter as the `agentsskills` target, and supporting files next to `SKILL.md` are carried along. See the [skills docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/Skills).\n\n> **Letta Code note:** Letta Code discovers directory-layout skills (`<name>/SKILL.md`) under the shared `.agents/skills/` root (project, the same directory the `agentsskills` target writes) and `~/.letta/skills/` (global, via `--global`). The `SKILL.md` frontmatter takes `name` and `description`; the optional keys Letta Code reads (`when_to_use`, `argument-hint`, `category`, `tags`, `disable-model-invocation`, `user-invocable`) round-trip through the `lettacode` frontmatter block, and supporting files next to `SKILL.md` are carried along. The two invocation gates fall back to the root-level rulesync value when the `lettacode` block omits them. See the [skills docs](https://docs.letta.com/configuration/skills/index.md).\n\n> **DeepSeek Harness note:** DeepSeek Harness scans `<projectRoot>/.dsh/skills` (project) and `~/.dsh/skills` (global, the `$DSH_HOME` default) for skills, alongside the `.agents/skills` roots the `agentsskills` target already writes, so rulesync emits `.dsh/skills/<name>/SKILL.md` in project mode and `~/.dsh/skills/<name>/SKILL.md` with `--global` and leaves the `.agents/` roots to `agentsskills`. The harness discovers only the **top level** of each root — a `<name>/SKILL.md` bundle or a flat `<name>.md` file — and deliberately ignores nested `**/SKILL.md`, which matches rulesync's output: one top-level bundle per skill, with supporting files carried inside it as plain files (rulesync never writes the flat form). Its frontmatter reads required `name`/`description` plus optional `whenToUse` (extra trigger-timing context), `metadata` (a free-form object), `disable-model-invocation` and `user-invocable`. Rulesync models all six: `metadata` and the two invocation flags fall back to the root-level shared defaults, and `whenToUse` is authored in the `dsh:` block. The harness also accepts `yes`/`no`, `on`/`off` and `1`/`0` spellings for the flags and drops the whole skill with a warning on any other value; rulesync writes and imports the canonical boolean form only, so an imported `SKILL.md` with `user-invocable: maybe` is rejected rather than silently dropped. There is no documented `license` or `compatibility` field, so those canonical fields are not written for `dsh`; the schema is loose, so an imported file carrying extra keys still parses. The harness also requires `name` to be kebab-case; rulesync does not enforce that for `dsh`, since the canonical skill is shared with every other target. See the [skill-filesystem README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) discovers Agent Skills (`<name>/SKILL.md`) under `.poolside/skills/` (project) and `~/.config/poolside/skills/` (global, via `--global` — only the XDG-default location is written). It also scans the shared `.agents/skills/` / `~/.agents/skills/` roots and other tools' skill directories, which belong to their own targets (`agentsskills` and friends), so each skill is still written exactly once. Only the required `name`/`description` pair is modeled — Pool does not enforce `allowed-tools` or `compatibility` — and the schema is loose, so extra keys survive an import. Pool only loads a skill whose directory name equals its `name`, so rulesync warns at generate time when the two differ. User-level skills are not mounted inside Pool's local sandboxes. See [Pool skills](https://docs.poolside.ai/skills).\n\n> **Goose note:** Goose discovers directory-layout skills (`<name>/SKILL.md`, with `name` and `description` frontmatter) under `.goose/skills/` (project) and `~/.config/goose/skills/` (global, via `--global` — the same `~/.config/goose/` root Rulesync uses for Goose recipes, custom agents and `config.yaml`). It also scans the shared `.agents/skills/` / `~/.agents/skills/` roots, which the `agentsskills` target writes, so generating both `goose` and `agentsskills` places each skill in two directories Goose reads. See [`skills/mod.rs`](https://github.com/aaif-goose/goose/blob/v1.53.0/crates/goose/src/skills/mod.rs).\n\n> **Crush note:** Crush auto-discovers standard Agent Skills (`<name>/SKILL.md`) under `.crush/skills/` in project mode and `~/.config/crush/skills/` in global mode (via `--global`) — the same `~/.config/crush/` root Crush's global `CRUSH.md` and MCP config live under, distinct from the project-only `.crush/` directory name. Unless `$CRUSH_SKILLS_DIR` is set, Crush also scans several shared directories it does not own (globally `~/.config/agents/skills/`, `~/.agents/skills/`, `~/.claude/skills/`; per-project `.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, also checked at a git worktree's common root) — rulesync writes only the Crush-specific path above, leaving those shared roots to their own targets. Besides `name`/`description`, the `crush:` block round-trips `user-invocable`, `disable-model-invocation`, and the shared `license`/`compatibility`/`metadata` packaging trio, all falling back to their root-level defaults when the section omits them; since Crush's `UserInvocable` field is a non-pointer Go `bool`, an omitted `user-invocable` resolves to `false`, keeping the skill reachable by the model but hidden from Crush's command palette. `compatibility` and `metadata` are normalized to the bare string / `map[string]string` shapes Crush's parser requires before being written, even if the rulesync root default holds the legacy object form. See [Crush's skill loader](https://github.com/charmbracelet/crush/blob/main/internal/skills/skills.go).\n\n> **Cline skills note:** Cline stores a skill's enabled state in the skill file itself: switching a skill off in the Cline UI (CLI config panel or the desktop app's Customize view) writes `disabled: true` into its `SKILL.md`, and Cline also treats `enabled: false` as disabled. Every key in the `cline:` section of `.rulesync/skills/*/SKILL.md` is written into the generated `.cline/skills/<name>/SKILL.md` (project) / `~/.cline/skills/<name>/SKILL.md` (global, via `--global`) — canonical `name` and `description` always win over same-named keys there — and `rulesync import --targets cline` lifts every key beyond `name`/`description` back into `cline:`. So a skill switched off in Cline stays off across `rulesync import` and `rulesync generate`; without that import, the next `generate` rewrites the file without the flag and re-enables the skill. `disabled` is not the canonical `disable-model-invocation`, which hides a skill from the model only, so the two are not mapped onto each other. See [`skill-frontmatter-toggle.ts`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/config/skill-frontmatter-toggle.ts).\n\n## `.rulesync/mcp.jsonc`\n\n`.rulesync/mcp.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/mcp.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nA key named `__proto__`, `constructor`, or `prototype` is rejected with an error naming its path, rather than being dropped in silence. The \"Rejected keys\" paragraph in the `.rulesync/permissions.jsonc` section below describes this handling, which is the same for all three source files.\n\nExample:\n\n```json\n{\n \"mcpServers\": {\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json\",\n \"serena\": {\n \"description\": \"Code analysis and semantic search MCP server\",\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\n \"--from\",\n \"git+https://github.com/oraios/serena\",\n \"serena\",\n \"start-mcp-server\",\n \"--context\",\n \"ide-assistant\",\n \"--enable-web-dashboard\",\n \"false\",\n \"--project\",\n \".\"\n ],\n \"env\": {}\n },\n \"context7\": {\n \"description\": \"Library documentation search server\",\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@upstash/context7-mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### Tool-scoped server blocks (`{toolname}.mcpServers`)\n\nServers under the shared `mcpServers` key are emitted to every targeted tool. To scope a server to a single tool, add a tool-scoped `{toolname}` block alongside it — mirroring `{toolname}.hooks` in `.rulesync/hooks.jsonc` and `{toolname}.permission` in `.rulesync/permissions.jsonc`:\n\n```jsonc\n{\n \"mcpServers\": {\n \"shared-server\": { \"type\": \"stdio\", \"command\": \"echo\" },\n },\n \"claudecode\": {\n \"mcpServers\": {\n // Added only to Claude Code's MCP config.\n \"claude-only-server\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" },\n // `null` removes a shared server for Claude Code only.\n \"shared-server\": null,\n },\n },\n}\n```\n\n- A tool-scoped entry with the same name as a shared server **replaces it wholesale** for that tool (no field-level merge).\n- A tool-scoped entry set to `null` **removes** the shared server for that tool.\n- Any MCP-capable `--targets` name is accepted as a block key (`claudecode`, `cursor`, `codexcli`, ...). Targets that share one output file resolve identically so the shared file never depends on generation order: the deprecated `claudecode-legacy` target reads the `claudecode` block; the `kiro-cli` / `kiro-ide` targets read the `kiro` block (all three write the same `.kiro/settings/mcp.json`); the `antigravity-ide` / `antigravity-cli` targets both apply both `antigravity-*` blocks in a fixed order (`antigravity-ide` first, then `antigravity-cli` — the CLI block wins per server) because they share their output file at both scopes (`.agents/mcp_config.json` in project mode, `~/.gemini/config/mcp_config.json` in global mode); and in project mode the `claudecode` (or `claudecode-legacy`), `codebuddy`, `commandcode` and `qoder` targets all apply the `claudecode`, `codebuddy`, `commandcode` and `qoder` blocks in that order (a later block wins per server) because they share the project-root `.mcp.json` — in global mode their files differ (`~/.claude.json`, `~/.codebuddy/.mcp.json`, `~/.commandcode/mcp.json` and `~/.qoder/settings.json`), so there each reads only its own block.\n\n> **Generation filter: per-server `enabled`.** Set `\"enabled\": false` on a server (in the shared map or a tool-scoped block) to keep the definition in the source file while emitting it to **no** tool config at all — a temporary off switch that does not lose the entry. Omitted means enabled, so existing configs keep generating everything; writing `\"enabled\": true` is opt-in clarity. This is distinct from the canonical `disabled`, which is a **pass-through** field the tools read (written as `disabled: true`, or translated to each tool's own spelling): `enabled: false` wins and drops the server entirely, while `disabled` only matters for servers still emitted. The field is rulesync-source-only and never reaches generated output — several tools (OpenCode, Kilo, Grok CLI, Goose) have a native `enabled` field with different semantics — and import never invents it: a tool's native enabled/disabled state keeps mapping to the canonical `disabled` (though a stray hand-written `enabled` in a passthrough-imported tool file does come back as the canonical filter). Two edges to know: a tool-scoped entry **replaces the shared entry wholesale**, so a same-named tool-scoped entry without `enabled: false` re-emits the server for that tool (per-tool re-enabling); and on merge-style shared configs (e.g. Hermes Agent's `config.yaml`), disabling a previously generated server stops writing it but does not remove the already-written entry — same as deleting the definition.\n\n> **Deprecated: per-server `targets`.** The older per-server `\"targets\": [\"tool\", ...]` array is still honored as a filter (a missing value or `[\"*\"]` means every tool), but it is deprecated and logs a warning at generate time. Migrate by moving the server into the matching `{toolname}.mcpServers` block(s).\n\n> **JetBrains AI Assistant note:** Rulesync writes the native `{ \"mcpServers\": { ... } }` configuration to `.ai/mcp/mcp.json` in project mode and `~/.ai/mcp/mcp.json` in global mode. Both scopes support STDIO and remote server entries using the shape documented in [JetBrains AI Assistant's MCP guide](https://www.jetbrains.com/help/ai-assistant/mcp.html).\n\n> **Antigravity note:** The Antigravity IDE and CLI share `.agents/mcp_config.json` (project) and `~/.gemini/config/mcp_config.json` (global). Antigravity CLI 1.1.24+ accepts `//` and `/* */` comments and trailing commas in `mcp_config.json` (see the [Antigravity CLI changelog](https://github.com/google-antigravity/antigravity-cli/blob/main/CHANGELOG.md)), so Rulesync reads the existing file as JSONC; the rewrite is plain JSON, so comments are dropped but the run no longer fails. HTTP/SSE servers use `serverUrl` in place of the canonical `url`, translated in both directions.\n\n#### JSON Schema Support\n\nRulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/mcp.jsonc`:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json\",\n \"mcpServers\": {}\n}\n```\n\n### Transport types (`type` / `transport`)\n\nThe `type` (and the equivalent `transport`) field accepts `local`, `stdio`, `sse`, `http`, `ws`, and `streamable-http`. `streamable-http` is the MCP specification's name for the HTTP transport and is accepted as an alias of `http`, so configurations copied from a server's documentation work unchanged. `ws` is the WebSocket transport (a persistent bidirectional connection) and accepts the same `url`/`headers`/`headersHelper`/`timeout` fields as `http`. Tools that do not recognize a given transport keep it on round-trip but may ignore it at runtime.\n\n> **OpenCode skills note:** on import, Rulesync also reads the `skills.paths` array of `opencode.json` / `opencode.jsonc` (\"Additional paths to skill folders\") and scans each entry as an extra skill root, so skills a project keeps outside `.opencode/skills/` are no longer invisible to `rulesync import`. These roots are import-only — generation keeps writing to Rulesync's own managed root — and a skill of the same name found in a managed root still wins. Each entry is resolved against the directory the config was read from — the project root in project mode, `~/.config/opencode/` in global mode — which is what OpenCode itself does. An absolute path is ignored, and so is one that escapes that directory — judged by where the entry resolves rather than by how it is spelled, so a relative entry that is a symbolic link pointing outside the directory is ignored too — and a directory under a configured root that is not a skill is skipped with a warning rather than failing the run, since a configured root is arbitrary user territory. `skills.urls` is a remote-fetch surface and is out of scope for a file-based generator. The OpenCode V2 flat `skills` array, which OpenCode V1 also accepts (from v1.18.24), is read the same way: its entries that are not `http(s)://` URLs are taken as `skills.paths`.\n\n> **MiMo Code skills note:** MiMo Code (an OpenCode distribution) reads skills from `.mimocode/skills/<name>/SKILL.md` (project) / `~/.config/mimocode/skills/<name>/SKILL.md` (global, via `--global`), also accepting the singular `skill/` directory on import. Tool-specific frontmatter goes under a `mimocode` key, which takes the same fields as the `opencode` key so a skill round-trips (MiMo Code itself ignores them). On import, the `skills.paths` array of `.mimocode/mimocode.json(c)` is read too, with relative entries resolved against the project root as MiMo Code does; a global config's relative entries depend on the project and are not read.\n\n> **oh-my-pi skills note:** oh-my-pi (`omp`) reads skills from `.omp/skills/<name>/SKILL.md` (project) / `~/.omp/agent/skills/<name>/SKILL.md` (global, via `--global`) in Pi's format. Tool-specific frontmatter goes under an `omp` key, which takes the same fields as the `pi` key and is handled the same way (see the Pi skills note).\n\n> **GitLab Duo CLI skills note:** GitLab Duo reads [Agent Skills](https://docs.gitlab.com/user/duo_agent_platform/customize/agent_skills/) from the repository-root `skills/<name>/SKILL.md` (project) and `~/.gitlab/duo/skills/<name>/SKILL.md` (global, via `--global`). The project `skills/` directory is read by GitLab Duo from the committed repository, so `rulesync gitignore` does not ignore it. Every skill targeting `gitlabduo` — including a `targets: [\"*\"]` one — is written there, so if your repository already uses a top-level `skills/` directory for something else (a skill collection, skills distributed to other tools), limit such skills' `targets` or leave `gitlabduo` out of the skills feature. User-level skills are an experiment and are loaded only when the CLI is started with `--enable-global-skills true` or `GITLAB_ENABLE_GLOBAL_SKILLS=true`. Rulesync always uses the default `~/.gitlab/duo/` location and does not follow `GLAB_CONFIG_DIR` or `XDG_CONFIG_HOME`. Because the project root `skills/` directory may hold unrelated content, only a subdirectory that contains a `SKILL.md` is imported. For the same reason `generate --delete` never removes a skill directory from the project `skills/` directory: hand-authored skills there are indistinguishable from ones Rulesync generated earlier, so a generated skill whose `.rulesync/skills/` source you removed has to be deleted by hand. (Stale files inside a skill directory that the run does generate are still cleaned up, as for every other target — so a hand-authored `skills/<name>/` sharing its name with a Rulesync skill is overwritten.) The global `~/.gitlab/duo/skills/` directory is swept as usual.\n\n> **OpenCode MCP note:** besides the mapped transport fields, Rulesync passes the OpenCode per-server keys `timeout`, `oauth`, `protocol` (`legacy` | `auto` | `2026-07-28`, OpenCode V2) and `codemode` (OpenCode V2) through verbatim in both directions. Other tools' MCP keys are never copied into `opencode.json`. Rulesync reads and writes OpenCode's V1 file shape — a flat `mcp` map keyed by server name — so `protocol` and `codemode` reach the adapter only when a canonical server carries them: authored by hand in `.rulesync/mcp.jsonc`, or imported from a flat-`mcp` `opencode.json` entry that already has them. They are written only in that case and never added on their own, so a V1-only install is unaffected unless you author them (OpenCode V1's published schema does not list them). A V2-shaped file (`mcp.servers`, `disabled`, the `timeout` object) is not read as such; keep those servers in the flat V1 shape, which OpenCode V2 still accepts, for Rulesync to import them. See the [OpenCode MCP docs](https://opencode.ai/v2/docs/mcp-servers/).\n\n> **Kilo Code note:** Kilo's MCP config uses its own native shape in `kilo.jsonc` (`type: \"local\" | \"remote\"`, `environment`, `enabled`, `command` as an array). Rulesync maps `stdio`/`local` ⇄ Kilo `local` and `http`/`sse` ⇄ Kilo `remote`; on import, Kilo `remote` is normalized to the canonical `http` transport (the deprecated `sse` is no longer emitted). The Kilo-specific `timeout` (local + remote, a positive integer in milliseconds) and `oauth` (remote only — either an OAuth-config object or `false` to disable auto-detection) fields are preserved on round-trip. The `kilo.jsonc` `skills` config key (`skills.paths` for extra skill locations and `skills.urls` for remote skill manifests) is likewise preserved when Rulesync writes the file. A bare `{\"enabled\": false}` entry — Kilo's way of switching off a server another config layer defines, such as the global config or a marketplace — round-trips as itself: it imports as a canonical server carrying only `disabled: true`/`disabled: false` and no transport, and a server in that shape is written back as `{\"enabled\": …}` rather than as a local server with an empty command it cannot start. The enabled state has to be stated outright in both directions: for a transport-less server that says nothing about `disabled`, a toggle already in `kilo.jsonc` is left exactly as it is, and if there is none the server is skipped with a warning — a toggle overrides the layer that defines the server, so writing `enabled: true` for it would switch back on what you turned off there. Kilo's per-tool `enabledTools`/`disabledTools` reach the generated file at all now — they used to be stripped before this adapter saw them, so a filter read out of `kilo.jsonc` was deleted from it on the next generate. The filters are written as Kilo [MCP tool permission keys](https://kilo.ai/docs/automate/mcp/using-in-kilo-code#auto-approve-tools) in the `permission` block — `enabledTools` as `\"{server}_{tool}\": \"allow\"`, `disabledTools` as `\"{server}_{tool}\": \"deny\"`, with both names sanitized the way Kilo names MCP tools (every character outside `a-z A-Z 0-9 _ -` becomes `_`, except that a `*` or `?` in a tool name is kept, so `disabledTools: [\"*\"]` becomes the wildcard key `\"{server}_*\"`) — rather than the legacy top-level `tools` map, which Kilo only folds into `permission` through a compatibility shim. The `permission` block is shared with the permissions feature: for every server the canonical config or the file's `mcp` block names, the MCP feature owns the plain `\"allow\"`/`\"deny\"` keys of that server's tools (including wildcard ones such as `\"{server}_*\"`) and rebuilds them on each generate (removing a `deny`, or a legacy `tools` entry set to `false`, that no `disabledTools` lists anymore warns), while other wildcard keys (such as the catch-all `\"*\"`), `\"ask\"`, the `{\"*\": …}` maps Kilo's \"Approve Always\" saves, Kilo's built-in permissions (such as `external_directory` for a server named `external`, or `repo_clone` for one named `repo`), and every other key are left alone; the same servers' entries are removed from a legacy `tools` map. Keys are matched to servers by name prefix, with the longest matching server name winning, so a key of a server defined only in another Kilo config layer whose name extends a listed one (`github_enterprise_…` next to a listed `github`) is treated as the listed server's. Note that in Kilo `\"allow\"` means the tool runs without asking, so an `enabledTools` entry is auto-approved even when a catch-all `\"*\": \"ask\"` comes before it. When two servers sanitize to the same key, a `deny` wins over an `allow`. Because Kilo evaluates `permission` last-match-wins in key order, the permissions feature moves exact `\"deny\"` keys to the end of the block, so a broad `allow` it appends can never lift an MCP `disabledTools` deny. A wildcard `disabledTools` entry (`\"{server}_*\": \"deny\"`) is not moved, because moving a wildcard deny would also override the narrower allows written after it on purpose; a catch-all `\"*\": \"allow\"` placed after it still wins, as it already did over the legacy `tools` map. A skipped server's filters are written either way, since a permission key is keyed by server name and reaches servers `mcp` does not list. On import, `permission` keys (a plain `\"allow\"`/`\"deny\"` or a `{\"*\": …}` map) of a listed server are read back as filters, and so is the legacy `tools` map (`permission` wins a key both state, as in Kilo's own shim); a legacy `tools` entry naming no listed server comes back as a server carrying nothing but the filters. A `permission` key naming no listed server is not imported as an MCP filter — it cannot be told apart from a built-in permission — and is imported by the permissions feature into its `kilo` override instead. A server with no transport — a toggle, or one of those filter-only entries — is imported into the tool-scoped `kilo.mcpServers` block rather than the shared `mcpServers` map, because an entry with no command and no url is a server the other tools' configs cannot start. All of this applies equally to OpenCode: its published schema carries the same bare-toggle union member, it round-trips a toggle as itself under the same explicit-state rule, its filters still use the top-level `tools` map (keyed by server name in the same way), and its transport-less servers land in `opencode.mcpServers`. The entry must carry no field of a local or remote server (`type`, `command`, `url`, `headers`, `environment`, `cwd`, `timeout`, `oauth`); an entry that is malformed in some other way still fails loudly rather than being quietly read as a toggle and written back with its command, headers, or OAuth secrets gone, while an unrelated key Kilo adds later is accepted rather than failing the run (it is not carried across the round-trip, though — a toggle imports as its enabled state and nothing else). Since a toggle keeps nothing but its enabled state, a canonical server that declares no transport but still carries fields such as `args` or `env` is written as a toggle with those fields dropped and a warning naming them. A server that names a transport it cannot reach — a `type` with no `command`, an `http` with no `url` — is skipped with a warning instead, because `{\"type\": \"local\", \"command\": []}` is a server Kilo cannot start. An existing `kilo.jsonc` carrying that shape (earlier Rulesync versions wrote it) imports as a server with no transport rather than failing the run. The same applies to OpenCode, whose config uses the same shape. Rejecting it used to fail the whole `--targets kilo` run rather than the MCP feature alone, because `kilo.jsonc` is the file the rules feature writes too. Environment variable references: Kilo expands only its own `{env:VAR}` syntax, and only in a trusted config. In global mode (`~/.config/kilo/kilo.jsonc`) the canonical `${VAR}` in `env` and `headers` values is written as `{env:VAR}`. A project `kilo.jsonc` is untrusted — any `{env:` in it makes Kilo drop the whole file (or, inside MCP `headers`, the server) — so in project mode the references are written literally as `${VAR}`, which Kilo does not expand, and a warning names the servers that carry them; a server that already carries `{env:` anywhere (for example Cursor's `${env:VAR}`) is skipped with a warning in project mode so it cannot cost the rest of the shared `kilo.jsonc`. On import, `{env:VAR}` maps back to `${VAR}` (Cursor's `${env:VAR}` is left untouched).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) uses OpenCode's MCP shape, written to the `mcp` (and `tools`) keys of `.mimocode/mimocode.jsonc` (project; an existing `.mimocode/mimocode.json` is updated instead) or `~/.config/mimocode/mimocode.jsonc` (global). Everything described for OpenCode applies, and a tool-scoped block goes under `mimocode.mcpServers`.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads the `mcpServers` map of `.omp/mcp.json` (project) / `~/.omp/agent/mcp.json` (global). Rulesync writes each server with an explicit `type` (`stdio`, `http` — also for `streamable-http` — or `sse`), a string `command` plus `args` for stdio servers, and `enabled: false` for a `disabled` server; other top-level keys of an existing file (`$schema`, `disabledServers`, …) are preserved, and the global file is never deleted. WebSocket servers are skipped with a warning. oh-my-pi expands `${VAR}` and `${VAR:-default}` itself, so values are written unchanged. `enabledTools` / `disabledTools` have no oh-my-pi counterpart and are not written. A tool-scoped block goes under `omp.mcpServers`.\n\n> **Pi note:** Pi (1.0+) has built-in MCP support and reads the `mcpServers` map of `.pi/mcp.json` (project — read only after project trust is granted) / `~/.pi/agent/mcp.json` (global). Rulesync writes each server with an explicit `type` (`stdio`, or `http` — also for `streamable-http`), a string `command` plus `args` for stdio servers, `enabled: false` for a `disabled` server, and `timeout` converted from Rulesync's milliseconds to Pi's seconds (a non-positive timeout is dropped with a warning, since Pi rejects the server for it); import reverses these mappings. Pi rejects the legacy SSE transport, so `sse` servers — like WebSocket servers and any remote server whose `url` is not `http(s)://` — are skipped with a warning. Pi-specific keys such as `exposure`, `toolExposure`, `description`, `oauth` and `auth` pass through; put them under a `pi.mcpServers` block to keep them out of other tools' configs. Pi accepts `auth` only in the global file, so it is dropped with a warning from the project file. Pi rejects server names other than letters, digits, `_` and `-`, and treats names that differ only in `-` and `_` as the same server, so such a name (or the second of two such names) is skipped with a warning. Other top-level keys of an existing file (`autoEnableCodemode`, …) are preserved, and the global file, which `pi mcp add` and `/mcp` also edit, is never deleted. Pi expands `${VAR}` and `$VAR` in `env` and `headers` values, so they are written unchanged (note that Pi runs a value starting with `!` there as a shell command); it does not expand `${VAR:-default}`, and in `command`, `args` and `cwd` it expands only a leading `~/`, so a server carrying such a reference gets a warning (write `~/...` rather than `${HOME}/...` there). `enabledTools` / `disabledTools` have no Pi counterpart and are not written. A project entry without `command`, `url` or `type`, which Pi treats as an override of a same-named user-level server, is not modeled: Rulesync skips a server without a transport. See the [Pi MCP docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md).\n\n> **GitLab Duo CLI note:** GitLab Duo reads [MCP servers](https://docs.gitlab.com/user/gitlab_duo/model_context_protocol/mcp_clients/) from `.gitlab/duo/mcp.json` (project) and `~/.gitlab/duo/mcp.json` (global) in the `{ \"mcpServers\": { ... } }` shape. Every server is written with an explicit `type` (`stdio`, `http` or `sse`), inferred from `command` / `url` when the source leaves it out; `streamable-http` becomes `http`, and servers with another transport (such as `ws`) are skipped with a warning. The GitLab-specific `approvedTools` (`true` or a list of tool names that run without a prompt) is passed through — put it under a `gitlabduo.mcpServers` block to keep it out of other tools' configs.\n\n> **Zed note:** Zed configures MCP servers under `context_servers` in its shared settings file (`.zed/settings.json` project, `~/.config/zed/settings.json` global — `%APPDATA%\\Zed\\settings.json` on Windows), whose value is an untagged shape with no `type` field: a stdio server is `{\"command\": <string>, \"args\", \"env\", \"timeout\"}`, a remote one `{\"url\", \"headers\", \"timeout\"}`, and an extension-provided one neither. Rulesync translates the canonical fields into those shapes instead of forwarding them verbatim (which used to hand Zed keys it silently ignores — most seriously `disabled: true`, which left the server **enabled**): `disabled: true` becomes `enabled: false` (and imports back as `disabled: true`), the `httpUrl` alias is normalized to `url`, an array `command` is flattened to Zed's single command string with the rest prepended to `args`, and canonical-only fields (`type`/`transport`, `alwaysAllow`, `trust`, `cwd`, `networkTimeout`, the Kiro lists) are dropped. Fields rulesync does not model — a remote server's `oauth` block, an extension server's `settings` — pass through untouched, so they are best authored in the tool-scoped `zed.mcpServers` block. A server Zed cannot start is skipped with a warning rather than written broken: an `sse` or `ws` server (Zed has neither transport), a remote server with no `url`, a local one with no `command`. A server with no transport at all is written as Zed's extension-provided variant, and on import such an entry lands in the tool-scoped `zed.mcpServers` block rather than the shared `mcpServers` map, since other tools cannot start it.\n\n> **Kimi Code note:** MCP servers are written to `.kimi-code/mcp.json` (project) and `~/.kimi-code/mcp.json` (global). Kimi Code supports stdio, HTTP, and SSE plus `env`, `cwd`, `headers`, `bearerTokenEnvVar`, `enabled`, `deferred` (Kimi Code 0.43.0: load the server's tools on demand instead of inline), `executor` and `runtime_id` (where a stdio server runs: `local` or `kaos`, and the runtime to use; accepted by Kimi's server schema though not yet documented), `startupTimeoutMs`, `toolTimeoutMs`, `enabledTools`, and `disabledTools`; Rulesync preserves the canonical fields that Kimi accepts. Canonical `local` maps to stdio and `streamable-http` maps to HTTP. WebSocket servers are skipped with a warning because Kimi has no WebSocket transport. A `kimi-code` block may also carry `startupTimeoutMs` / `toolTimeoutMs`, which are **not** per-server: they become Kimi's `[mcp] startup_timeout_ms` / `tool_timeout_ms` defaults in the shared global `~/.kimi-code/config.toml`, applying to every MCP server including ones Rulesync did not write (a per-server value in `mcp.json` still wins). Global scope only, since `config.toml` has no project counterpart, and merged in place so the `hooks` and `permission` sections of the same file survive. The merge is per key: authoring only one of the two timeouts leaves a hand-written sibling alone, and dropping the override entirely leaves the section as it stands rather than deleting it — remove the keys from `config.toml` by hand if you want them gone. See the [Kimi Code MCP docs](https://moonshotai.github.io/kimi-code/en/customization/mcp.html) and [config-files reference](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#mcp).\n\n> **Hermes Agent note:** Hermes MCP servers live under `mcp_servers` in the shared `~/.hermes/config.yaml`. Rulesync preserves the `oauth` keys Hermes reads (`redirect_uri`, `redirect_host`, `redirect_port`, `client_id`, `client_secret`, `client_name`, `client_metadata_url`, `cimd`, `token_endpoint_auth_method`, `application_type`, `user_agent`, `flow`, `timeout`, and `scope`) — `scope` is the space-separated string Hermes sends on registration and device login; a `scopes` list, which earlier Rulesync versions wrote but Hermes never read, is folded into `scope` in both directions — plus `protocol` (`auto` / `stateless` / `legacy`, v0.21), `lazy` (defer the connection to first tool use, v0.21), `idle_timeout_seconds`, `max_lifetime_seconds` (also under a `lifecycle:` mapping, carried as an opaque object), `ssl_verify` (`true`/`false` or a PEM CA-bundle path), `skip_preflight`, `keepalive_interval` (liveness ping cadence in seconds), `trust` (`full` or `untrusted`, where every write-capable tool call needs approval — copied verbatim, since Hermes reads any unrecognized value as `untrusted`), and the `sampling`, `elicitation`, and `identity_header` mappings (carried as opaque objects so new sub-keys keep working). A canonical `sse` server is written with Hermes's own `transport: sse` (v0.20.0) and imports back as `type: \"sse\"`; without it Hermes connects to a `url` server over Streamable HTTP, so the transport would silently change. Streamable HTTP is Hermes's default and stays implicit. On import, portable server fields remain in shared `mcpServers`; Hermes-only fields are isolated in the full `hermesagent.mcpServers.<name>` replacement block so they cannot leak to other targets.\n\n> **Devin note:** Since Devin v3000.3 (the Local 3.6 release), MCP servers live in a dedicated `mcpServers`-keyed file: `.devin/mcp_config.json` (project) and `~/.config/devin/mcp_config.json` (global, via `--global`). The file is MCP-only and rulesync-owned (rewritten whole, deletable), unlike the shared `.devin/config.json` that permissions and hooks keep patching in place. Rulesync no longer writes the legacy `config.json` `mcpServers` key — Devin auto-migrates it away on startup, so re-seeding it would fight the migration — but import still falls back to that key when no `mcp_config.json` exists, so pre-v3000.3 repos migrate cleanly. The gitignored personal override `.devin/mcp_config.local.json` is never read or written (it is covered by the derived `.gitignore`). See the [Devin MCP configuration docs](https://docs.devin.ai/cli/extensibility/mcp/configuration). The `devin-plugin` target instead writes a plugin bundle's `.mcp.json` under `mcpServers` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Warp note:** Warp reads file-based MCP servers from `.warp/.mcp.json` (project) and `~/.warp/.mcp.json` (global). Warp spells the working directory `working_directory` (used for resolving relative paths), so the canonical `cwd` is translated to it on generate and back on import; a tool-native `working_directory` already on the server wins over `cwd`. See the [Warp MCP docs](https://docs.warp.dev/agent-platform/capabilities/mcp/).\n\n> **Warp Agent CLI note:** The standalone Warp Agent CLI (the `warp` binary, target `warpcli`) keeps its own config root, separate from the Warp app's: `~/.warp_cli/` on macOS, `~/.config/warp-terminal/cli/` on Linux, and `%LOCALAPPDATA%\\warp\\Warp\\config\\cli\\` on Windows. It reads MCP servers from `.mcp.json` in that directory only — \"Project-scoped MCP config files in repositories are not detected\" — so `warpcli` is **global-only** (`--global`). The file format is the Warp app's (including the `cwd` ⇄ `working_directory` translation), and a `warpcli` tool-scoped block in `.rulesync/mcp.jsonc` applies to it alone. Rules and skills are shared with the app (`AGENTS.md`, `.agents/skills/`), so keep generating them with the `warp` target. See the [Warp Agent CLI configuration docs](https://docs.warp.dev/agents/cli/configuration/).\n\n> **Pool note:** Pool reads MCP servers from the `mcp_servers` block of its YAML settings file: `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) and `~/.config/poolside/settings.yaml` (global, via `--global`). The merge is in place — every other top-level key (`pool`, `tools`, `sandbox`, `hooks`, …) is preserved and the file is never deleted. Stdio servers are written as `command`/`args`/`cwd`/`env`; remote servers as a `transport` block (`type: http` or `type: sse`, `url`, and `headers` as Pool's list of `\"Name: value\"` strings, which imports back to the canonical map). `streamable-http` is written as `http`; `ws` servers are skipped with a warning because Pool offers no WebSocket transport. Tool filters map onto Pool's own switches: `enabledTools` becomes `enabled_tools` (the tools Pool exposes at all) and `disabledTools` becomes `deny` (patterns agents may never use); Pool's approval allowlist is authored on the canonical server as the namespaced `poolAllow` (written as `allow`, imported back as `poolAllow`, and stripped from every other tool's output). Note that Pool's `deny`/`enabled_tools` accept glob patterns while the canonical lists are literal tool names, so a pattern imported from Pool is carried to other tools' `disabledTools`/`enabledTools` as a literal name — the import warns when it sees one (`allow` is exempt, since `poolAllow` never reaches another tool). Because `.poolside/settings.yaml` is committed, prefer `env` entries that reference environment variables (or Pool's own `secrets` key) over literal credentials in `headers`. See the [Pool MCP docs](https://docs.poolside.ai/mcp-servers) and the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **DeepSeek Harness note:** DeepSeek Harness (`dsh`) has no MCP-only file: an MCP server is an `insert` row for the bundled `@deepseek-ai/dsh-mcp-client` plugin in a Cordis patch layer. Rulesync writes those rows into the user-owned home layer `~/.dsh/cordis.patch.yml` (the `$DSH_HOME` default), so `dsh` is **global-only** (`--global`); the profile layer is written by the app itself and there is no project-scoped MCP file. The file is a YAML list of patch entries, and the merge is in place: rulesync owns every `@deepseek-ai/dsh-mcp-client` row. A server that already has a row is rewritten where that row stood, in the same entry (so a row inserted into a group stays in that group), reusing its `id` so id-targeted patches keep applying; rows of servers no longer defined are removed; new servers are appended to the first plain top-level `insert` entry (or a new one), with the id `mcp-<name>` (suffixed if that id is already taken). Every other patch entry and insert row — including their `!!js` expressions — is preserved, and the file is never deleted. A file whose root is not a list is refused rather than overwritten. YAML comments are not preserved on rewrite and anchors/aliases are expanded, and a `!!js` expression inside an MCP row rulesync rewrites is replaced by the canonical value. On import, a `!!js` value in `env` or `headers` is dropped, and a row whose `serverName`, `transport`, `command`, `url`, `cwd`, `args`, `disabled` or one of the dsh-only fields below is (or, for `reconnect`, contains) a `!!js` expression is skipped with a warning — define that server in `.rulesync/mcp.jsonc` yourself, since the next generate removes the row otherwise. Stdio servers become `transport: stdio` with `command`/`args`/`env`/`cwd`; `http`/`streamable-http` servers become `transport: streamable-http` with `url`/`headers`; `sse` and `ws` servers are skipped with a warning because the client only speaks stdio and Streamable HTTP, and so is a server whose name does not match the harness's `[A-Za-z0-9_-]{1,32}` pattern. Canonical `disabled: true` becomes the entry-level `disabled` switch. The client-only `toolCallTimeoutMs`, `failOnStartupError`, `maxInstructionBytes` and `reconnect` fields are carried when authored on a server; import puts them in a `dsh.mcpServers` replacement block so they never leak to other tools; canonical `timeout` is not mapped. `enabledTools`/`disabledTools` are not supported. See the [dsh-mcp-client README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md).\n\n> **Takt note (partial / transport-allowlist only):** Takt does **not** have a project- or global-level registry of MCP server _definitions_. The concrete `mcp_servers` map (`command`/`args`/`env` or `type`/`url`/`headers`) is declared **per workflow step** inside individual workflow YAML files; there is no top-level `mcp_servers` key in `config.yaml`, and Takt's config loader hard-rejects unknown top-level keys (introduced with MCP support in [Takt v0.21.0](https://github.com/nrslib/takt/blob/main/CHANGELOG.md)). What `config.yaml` _does_ hold is the **default-deny transport allowlist** `workflow_mcp_servers: { stdio, sse, http }` — without it, workflow-defined MCP servers are refused regardless of how they are declared. So Rulesync emits **only** this allowlist into the shared `.takt/config.yaml` (project) / `~/.takt/config.yaml` (global), enabling exactly the transports your `.rulesync/mcp.jsonc` servers use (`local`/`stdio` ⇒ `stdio`; `sse` ⇒ `sse`; `http`/`streamable-http`/`ws` ⇒ `http`). The merge is in place — every other top-level key (`provider`, `provider_profiles`, …) is preserved and the file is never deleted. **Documented lossiness:** per-server names, commands, env, URLs, and headers are not representable in `config.yaml` and are intentionally **not** written; you still declare the concrete servers in your workflow YAML steps, and Rulesync only opens the transport gate that permits them. As a corollary, **import** cannot reconstruct server definitions from a transport allowlist and yields an empty `mcpServers` map. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).\n\n### MCP Tool Config (`enabledTools` / `disabledTools`)\n\nYou can control which individual tools from an MCP server are enabled or disabled using `enabledTools` and `disabledTools` arrays per server.\n\n```json\n{\n \"mcpServers\": {\n \"serena\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"--from\", \"git+https://github.com/oraios/serena\", \"serena\", \"start-mcp-server\"],\n \"enabledTools\": [\"search_symbols\", \"find_references\"],\n \"disabledTools\": [\"rename_symbol\"]\n }\n }\n}\n```\n\n- `enabledTools`: An array of tool names that should be explicitly enabled for this server.\n- `disabledTools`: An array of tool names that should be explicitly disabled for this server.\n\n> **Kiro note:** Kiro MCP servers are written under `mcpServers` in `.kiro/settings/mcp.json` (project) and `~/.kiro/settings/mcp.json` (global). Kiro supports `disabledTools` natively and Rulesync preserves it on generate and import. Kiro does not expose a corresponding per-server `enabledTools` allowlist, so that field is omitted for Kiro targets. The two Rulesync-only authoring keys are translated onto the field names Kiro actually reads: `kiroAutoApprove` becomes `autoApprove` (tools run without a confirmation prompt) and `kiroAutoBlock` becomes `disabledTools` (tools hidden from the agent). A server that already spells `autoApprove` or `disabledTools` natively keeps working — the two lists are merged rather than one overwriting the other. On import, `autoApprove` is lifted back into `kiroAutoApprove`; `disabledTools` stays as-is because it is already a canonical Rulesync field with the same meaning, so `kiroAutoBlock` has no import counterpart. That makes `kiroAutoBlock` a redundant spelling of `disabledTools`: a Kiro config imported after a generate comes back as canonical `disabledTools`, which then also reaches the other targets that support it. Prefer authoring `disabledTools` directly, which makes that scope explicit from the start.\n\n> **Roo Code / Zoo Code note:** Both targets write `.roo/mcp.json` through the same adapter, and their per-server MCP schema is **denylist-only**: `disabledTools` clears each named tool's `enabledForPrompt` (the tool stops being offered to the model), and there is no corresponding `enabledTools` allowlist, so that field is omitted for these targets. The server config is emitted verbatim, so `disabledTools` round-trips as itself. Earlier Rulesync versions stripped it before the adapter saw it, which meant a filter you had written into `.roo/mcp.json` by hand — or through Zoo Code's own tool toggles, which write the same key — was deleted on the next generate.\n\n> **deepagents note:** MCP servers are written to `.deepagents/.mcp.json` (project) / `~/.deepagents/.mcp.json` (global). Several translations apply, because dcode **drops an individual server it cannot validate** — the rest of the file still loads, so a mistake here is silent rather than loud. **Transports:** dcode accepts only `stdio`, `sse` and `http` (plus the aliases `streamable_http` / `streamable-http` → `http`), so canonical `local` is written as `stdio` and `streamable-http` as `http`; canonical `ws` has no counterpart and the server is skipped with a warning at generate time, where you can still see it. Whether the value lands under `type` or `transport` follows whichever key you authored — dcode reads the two interchangeably, and with neither set it infers `http` from a `url` and `stdio` otherwise. On import, both spellings of the `streamable_http` alias come back as canonical `http`. **Tool filters:** canonical `enabledTools` becomes `allowedTools` (dcode never reads `enabledTools`, and it ignores unknown keys silently, so forwarding the canonical name would be a no-op), while `disabledTools` carries its own name; each entry is a tool name or an `fnmatch` glob, and import lifts `allowedTools` back. Upstream rejects a server that sets **both** filters and rejects an **empty** list, and each case is resolved the way that does not hand the model more tools than your canonical config allows. Setting both is valid canonically — other targets apply the two lists independently — but has no form here, so the **server is skipped** with a warning rather than written without filters, which would leave it running with every tool including the denied ones. An empty `enabledTools` likewise **skips the server**, since an allowlist of nothing means no tools at all and dropping the key would publish all of them. An empty `disabledTools` is the one genuine no-op, so only that key is dropped (with a warning) and the server is still written. **Server shapes:** dcode validates each entry into FastMCP 4's typed server models, so three canonical shapes are normalized as well. An `httpUrl` is written as `url` (dcode never reads the `httpUrl` alias and would take such a server for stdio) with the transport pinned to `http` when none is set, since `httpUrl` names a streamable HTTP endpoint (an authored `type` or `transport` is kept); when both are set, `url` wins and `httpUrl` is dropped. An array `command` is split into its first element and an `args` list that the rest is prepended to, because FastMCP accepts only a string `command`; an empty array leaves nothing to spawn, so that server is skipped with a warning (on a remote server the empty `command` is simply dropped). A server with `disabled: true` is **skipped** with a warning, because dcode has no per-server `disabled` key — its only off switch is the server viewer's `[mcp].disabled_servers` toggle in `config.toml` — and writing the key would leave the server running; `disabled: false` is simply dropped. See the [MCP tools docs](https://docs.langchain.com/oss/deepagents/code/mcp-tools).\n\n> **Qwen Code note:** MCP servers are written to the `mcpServers` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global, via `--global`). Qwen supports stdio (`command`/`args`), SSE (`url`), and HTTP (`httpUrl`) transports. Rulesync maps the canonical per-server `enabledTools` ⇄ Qwen's `includeTools` (allowlist) and `disabledTools` ⇄ Qwen's `excludeTools` (denylist). Other top-level keys in `settings.json` are preserved on round-trip. In project mode, import also reads the Claude-parity `.mcp.json` at the project root (loaded by Qwen Code since v0.18.1) and merges its servers beneath those of `.qwen/settings.json`, so a server defined in both keeps its `settings.json` definition, matching Qwen Code's precedence (user settings < project `.mcp.json` < workspace settings); generation still writes only `.qwen/settings.json` and never touches `.mcp.json`, so after an import and a regenerate the imported servers live in both files and the `settings.json` copy wins — edit them through `.rulesync/mcp.jsonc` from then on. A malformed `.mcp.json` is skipped with a warning. Qwen's server-level allow/deny lists — `mcp.allowed` and `mcp.excluded` in `settings.json`, MCP server names or `*` / `?` globs over the `mcpServers` keys, with `excluded` winning when a server matches both — are authored as `allowed` / `excluded` in the `qwencode` block of `.rulesync/mcp.jsonc` (for example `\"qwencode\": { \"allowed\": [\"my-trusted-server\", \"*-internal\"], \"excluded\": [\"experimental-server\"] }`) and imported back into that block. They gate every server Qwen Code loads, including ones rulesync did not write. Each list is written only when authored, so a list you leave out of the block — and the other keys of the `mcp` object, such as `mcp.serverCommand` — keep whatever value `settings.json` already holds; removing a list from the block therefore does not remove it from `settings.json`. To clear a list, author it as an empty array (`\"excluded\": []`) for one generate, or delete it from `settings.json` by hand. See the [Qwen Code MCP docs](https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/).\n\n> **Codex CLI server-name note:** Codex requires MCP server names matching `[a-zA-Z0-9_-]+`, so Rulesync auto-normalizes non-conforming names on generate (lowercase, runs of other characters become `_`, leading/trailing `_` trimmed) — e.g. `Postgres MCP - Production - Read Only` becomes `postgres_mcp_production_read_only`. If two names normalize to the same Codex name, the last processed server overwrites the earlier one (with a warning). A name with no representable characters at all (e.g. a fully Japanese name) falls back to a stable hash-derived name like `mcp_1a2b3c4d` instead of being dropped; rename the server in `.rulesync/mcp.jsonc` to pick a readable Codex name. This normalization is one-way: importing back from the generated `config.toml` yields the normalized name, not the original.\n\n> **Codex CLI key-translation note:** Codex's `[mcp_servers.<name>]` table reads its own field names, so the canonical fields are translated rather than forwarded. `headers` is written as `http_headers` (and imports back as `headers`), which Codex accepts only on a url-based server — on a stdio server it is a load error upstream, so the headers are dropped with a warning instead. The same applies to the other keys Codex's `throw_if_set` rejects for a server's transport, since one such key fails the whole `config.toml`: on a stdio server `url`, `bearer_token_env_var`, `bearer_token`, `http_headers_helper`, `env_http_headers`, `oauth`, `oauth_resource` and `auth` are dropped with a warning, and on a url-based server `args`, `env`, `env_vars`, `cwd` and `bearer_token` are. The canonical millisecond timeouts become Codex's second-based ones: `timeout` ⇄ `tool_timeout_sec` (the default timeout for tool calls) and `networkTimeout` ⇄ `startup_timeout_sec` (initialize + list-tools), dividing by 1000 on generate and multiplying on import, so a sub-second remainder is emitted as a fraction. Codex also accepts `startup_timeout_ms`, which imports verbatim into `networkTimeout` unless the config sets `startup_timeout_sec` too — Codex prefers the seconds spelling when both are present, and so does Rulesync. The canonical `tools` array is **not** written: Codex declares `tools` as a table of per-tool approval settings (`tools.<tool>.approval_mode`), and an array where it expects a table is a hard deserialization error that takes the whole server entry down, so it is dropped with a warning — use `enabledTools` / `disabledTools`, which map onto Codex's `enabled_tools` / `disabled_tools`. Codex's table itself is authored through the namespaced `codexcliTools` key and imports back into it (see [below](#codex-specific-per-tool-approval-modes-codexclitools)). Both timeouts must be non-negative: Codex builds a duration out of them and errors on a negative value, which fails the whole file, so such a value is dropped with a warning. Canonical fields Codex has no counterpart for (`type`/`transport` — Codex infers the transport from `command` versus `url` — plus `alwaysAllow`, `trust`, and the Kiro lists) are dropped silently; on import a server carrying a `url` and no `command` gets `type: \"http\"` restated, so a config read out of Codex still reaches the tools that branch on the transport. That restatement is one-way, like the server-name normalization: Codex's only remote transport is `streamable_http`, so a canonical `sse` (or `ws`, or `streamable-http`) server comes back from a round-trip as `http`. Fields Rulesync does not model, such as `env_http_headers` and `bearer_token_env_var`, pass through under their own names.\n\n> **Codex CLI environment-variable note:** Codex does not expand `${VAR}` references in `url` or `http_headers` — the values are sent literally. On generate, Rulesync therefore rewrites the two header shapes Codex can source from the environment: `Authorization: Bearer ${VAR}` becomes `bearer_token_env_var = \"VAR\"`, and a header whose whole value is `${VAR}` becomes an `env_http_headers` entry (`\"X-Api-Key\" = \"VAR\"`). The header name and the `Bearer` scheme are matched case-insensitively. A `bearer_token_env_var` or `env_http_headers` entry you set explicitly on the server wins: the header that would have been converted into it is dropped instead of being written a second time. Anything else still containing `${…}` — a reference inside a longer value, a `${VAR:-default}` fallback, or any reference in `url` (for which Codex has no environment-sourced counterpart) — is written verbatim with a warning naming the server and field, so replace it with a literal value for Codex. The rewrite is one-way: importing from Codex keeps `bearer_token_env_var` and `env_http_headers` as pass-through keys rather than restoring the canonical `headers` entries.\n\n### Codex-specific: pass shell env vars to MCP servers (`envVars`)\n\nCodex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format.\n\nThis is distinct from `env` (which is a literal `{name: value}` map) — `envVars` is a list of names whose **values come from the user's environment at runtime**. Both fields may coexist on the same server.\n\n```json\n{\n \"mcpServers\": {\n \"pal\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\n \"--from\",\n \"git+https://github.com/BeehiveInnovations/pal-mcp-server.git\",\n \"pal-mcp-server\"\n ],\n \"envVars\": [\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"GEMINI_API_KEY\"]\n }\n }\n}\n```\n\nGenerated `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.pal]\ntype = \"stdio\"\ncommand = \"uvx\"\nargs = [\"--from\", \"git+https://github.com/BeehiveInnovations/pal-mcp-server.git\", \"pal-mcp-server\"]\nenv_vars = [\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"GEMINI_API_KEY\"]\n```\n\nAn entry may also be an object naming the environment to read the variable from: `{ \"name\": \"REMOTE_TOKEN\", \"source\": \"remote\" }` reads it from the remote executor environment (and requires remote MCP stdio support), while a bare name and `\"source\": \"local\"` read from Codex's own environment. The object form is written to `config.toml` as an inline table, matching Codex's documented shape. Only `name` and `source` are accepted in that object — Codex rejects an unknown key there, and rejecting one server's entry would take the whole `config.toml` down with it, so Rulesync fails on the canonical file instead. For the same reason an entry that a `config.toml` already holds in some other shape is dropped with a warning on import rather than written into a `.rulesync/mcp.jsonc` the next generate would refuse.\n\n- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).\n- Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`.\n- Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule.\n\n### Codex-specific: run a stdio server remotely (`experimentalEnvironment`)\n\nFor stdio servers, `experimentalEnvironment: \"remote\"` starts the server through a remote executor environment when one is available. It is written as `experimental_environment` in `config.toml`. Like `envVars`, it is stripped before every other tool's MCP config is written, so it cannot leak into a config that would not understand it — and for the same reason, a server config copied straight out of a `config.toml` may spell it `experimental_environment`, which is accepted and normalized on the way to Codex.\n\nSee the [Codex MCP reference](https://learn.chatgpt.com/docs/extend/mcp) for both fields.\n\n#### Codex-specific: OAuth keys (`oauth.clientId` → `client_id`)\n\nA server's `oauth` block is preserved in the canonical Claude Code shape (camelCase `clientId`, `callbackPort`), but Codex CLI reads only the snake_case keys of its own `oauth` table: `client_id`, `client_secret`, `callback_url`, `callback_port` and `authorization_server_issuer`. Without `client_id`, `codex mcp login <server>` falls back to dynamic client registration and fails for providers that do not support it (e.g. Slack). The codex generator therefore **renames** `clientId` to `client_id` and `callbackPort` to `callback_port`, passes the Codex-native keys through (an explicit native key wins over its canonical spelling), and drops every other key, such as Claude Code's `authServerMetadataUrl`, with a warning — since Codex 0.155.0 it reports such a key as an ignored setting at startup:\n\n```toml\n[mcp_servers.slack.oauth]\nclient_id = \"1601185624273.8899143856786\"\ncallback_port = 3118\n```\n\nA value of the wrong type (a non-string client id, a `callbackPort` that is not an integer port) is dropped with a warning too, because Codex would fail to load the whole `config.toml` over it — as is a `client_secret` that is blank or comes without a non-blank client id, which Codex rejects the same way. On import, `client_id` and `callback_port` are renamed back to `clientId` and `callbackPort`; when a file carries both spellings (earlier Rulesync versions wrote `clientId` next to `client_id`), the canonical key wins so the round-trip stays stable.\n\n#### Codex-specific: transport normalization\n\nCodex reads `command` as a single string and has no `httpUrl` key, and either mismatch stops the whole `config.toml` from loading — disabling the permissions profile and approval policy along with MCP. The codex generator therefore splits an array `command` into its program plus leading `args` (canonical `args` follow), and writes `httpUrl` as `url` (an explicit `url` wins). A server whose `command` array is empty and that has no url is skipped with a warning.\n\n### Codex-specific: per-tool approval modes (`codexcliTools`)\n\nCodex reads per-tool settings from `[mcp_servers.<name>.tools.<tool>]` tables: `approval_mode` (`auto`, `prompt`, `writes` or `approve`) overrides the server's `default_tools_approval_mode` for one tool, and `output_token_limit` (a positive integer) sets that tool's output token budget. Author them as `codexcliTools`, a map keyed by tool name — usually inside the `codexcli` block, since no other tool reads it. The canonical `tools` key cannot be used for this: it is a string array, and Codex reads its own `tools` key as this table.\n\n```jsonc\n{\n \"mcpServers\": {},\n \"codexcli\": {\n \"mcpServers\": {\n \"example\": {\n \"type\": \"http\",\n \"url\": \"https://example.com/mcp\",\n \"default_tools_approval_mode\": \"writes\",\n \"codexcliTools\": {\n \"example_tool\": { \"approval_mode\": \"approve\" },\n },\n },\n },\n },\n}\n```\n\nGenerated `.codex/config.toml`:\n\n```toml\n[mcp_servers.example]\nurl = \"https://example.com/mcp\"\ndefault_tools_approval_mode = \"writes\"\n\n[mcp_servers.example.tools.example_tool]\napproval_mode = \"approve\"\n```\n\n- `codexcliTools` may also sit on a shared server; it is stripped before every other tool's MCP config is written, like `envVars`.\n- The two known fields are validated strictly, because Codex rejects the whole `config.toml` on an unknown `approval_mode` or a zero `output_token_limit`; any other key in an entry is passed through.\n- Approvals Codex itself saved into `config.toml` (for example after \"always allow\") are kept on regenerate. The authored table is merged over them field by field: a field set in `codexcliTools` wins, and every saved tool or field it does not name is kept (authoring only `output_token_limit` for a tool keeps its saved `approval_mode`). Removing a tool from `codexcliTools` therefore does not remove its entry from `config.toml`; delete it there by hand.\n- On import, the `tools` table is lifted into `codexcliTools` on the imported server. An entry Rulesync would refuse on the next generate is dropped with a warning. The table includes approvals Codex saved for you locally, so review them before committing the imported `.rulesync/mcp.jsonc`: once committed, they apply to everyone who generates from it.\n\nSee the [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference) (`mcp_servers.<id>.tools.<tool>.approval_mode` / `output_token_limit`).\n\n> **Grok CLI note:** MCP servers are written to a `[mcp_servers.<name>]` table in `.grok/config.toml` (project) / `~/.grok/config.toml` (global, via `--global`). The file is treated as shared Grok config: Rulesync only replaces the `mcp_servers` key (removing it, rather than leaving an empty table, when no server is left) and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal `env` table (it does not support the `env_vars` runtime-passthrough list), so the only field rename is `disabled` (rulesync) ⇄ `enabled = false` (grok); an active server simply omits `enabled`. A server's `disabledTools` is written to Grok's top-level `[disabled_mcp_tools]` table (`<server> = [\"tool\", ...]`, unqualified tool names) in global mode only, because Grok reads that table from `~/.grok/config.toml` alone; in project mode it is dropped with a warning. Rulesync sets or removes the entry of each server it declares and keeps every other entry (servers toggled in Grok's `/mcps` UI, and Grok's own `__managed_gateway_connectors` entry), and it imports the entries of the servers in `[mcp_servers]` back as `disabledTools`. Grok has no per-server tool allowlist, so `enabledTools` is dropped with a warning. Servers with no environment variables are emitted without a dangling `[mcp_servers.<name>.env]` table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning.\n\n### Goose-specific: MCP servers as `extensions` (global) and open-plugin manifest (project)\n\nGoose configures MCP servers in two locations depending on scope:\n\n- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `cwd` → `cwd` (stdio only), `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. A canonical server with no `command` and no `url` is **skipped with a warning** rather than written as a `stdio` extension with no `cmd`, which Goose cannot start. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. The `extensions:` map itself is co-owned: Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions (`developer`, `memory`, ...) live there alongside MCP servers and are **carried over untouched**, as is any entry Rulesync cannot read as an MCP server, while every entry it positively identifies as one (`stdio`/`streamable_http`/`sse`) is Rulesync-owned, so a server deleted from `.rulesync/.mcp.json` is retracted with a warning naming it. Import mirrors this: a non-MCP extension type is skipped with a warning instead of being imported as a server (importing a `builtin` used to strip the type that makes it work). This location supports **both stdio and remote** (http/sse) servers.\n- **Project:** Goose v1.39.0+ discovers MCP extensions in **open plugins** at `<project>/.agents/plugins/<name>/.mcp.json` (and `~/.agents/plugins/<name>/.mcp.json` at user scope). Rulesync emits `.agents/plugins/rulesync/.mcp.json`, reusing the same `.agents/plugins/rulesync/` tree already used for Goose hooks. The manifest uses the **Claude-style** `{ \"mcpServers\": { \"<name>\": { \"command\", \"args\", \"env\", \"cwd\" } } }` shape. This manifest is **stdio-only** — it cannot express `url`/`headers`, so **remote (http/sse) servers are skipped with a warning** in project mode; sync them with `--global` to `~/.config/goose/config.yaml` instead. The `.mcp.json` manifest is owned by Rulesync and is deleted when no servers remain.\n\nSee the [Goose extensions docs](https://goose-docs.ai/docs/getting-started/using-extensions/) and [open-plugins MCP PR #9471](https://github.com/aaif-goose/goose/pull/9471).\n\n### Goose-specific: commands as recipes, subagents as custom agents\n\nGoose [recipes](https://goose-docs.ai/docs/guides/recipes/recipe-reference/) are reusable YAML workflow files. **Commands** map to top-level recipes at `.goose/recipes/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global); the command body becomes the recipe `prompt`, `title` defaults to the file name and `description` to the rulesync `description` (falling back to `title`), `version` defaults to `1.0.0`, and any other recipe field round-trips through the rulesync `goose` section of a command.\n\nA recipe on disk is not invocable as `/name` on its own: Goose resolves slash commands from the `slash_commands` list in the user config (`~/.config/goose/config.yaml`), whose entries are `{ command, recipe_path }` pairs. In **global mode** Rulesync therefore registers every generated recipe there. `recipe_path` is written as an **absolute** path, because Goose resolves it with a bare `PathBuf::from(...)` on this code path (the tilde expansion used by `goose run --recipe` does not apply, so a `~/…` registration would never resolve), and the command name is lowercased, because Goose lowercases the typed command and compares it against the stored value verbatim. There is no project-level registration surface upstream, so project-scope recipes must still be run with `goose run --recipe`.\n\nThe list is co-owned: entries whose `recipe_path` points outside `~/.config/goose/recipes/` — and sub-recipes under `recipes/subagents/` — are carried over untouched, while **every** entry pointing directly into that directory is Rulesync-owned and recomputed on each `--global` generate. That retracts a deleted command's registration and drops the key once nothing is registered, but it also means a slash command you registered yourself (via Goose's own UI or `goose recipe`) for a recipe living in that directory is removed on the next generate — keep such recipes elsewhere, or author them in `.rulesync/commands/`. Command names must be unique, contain no spaces, and must not shadow a built-in command such as `/recipe`, `/compact`, or `/help`; Rulesync does not check the built-in names for you. See the [slash-command mapping in the Goose source](https://github.com/aaif-goose/goose/blob/main/crates/goose/src/slash_commands/recipe_slash_command.rs).\n\n**Subagents** map to Goose's [custom agents](https://goose-docs.ai/docs/guides/context-engineering/custom-agents/) (v1.34.0+): Markdown files with `name` (required) / `description` / `model` frontmatter whose body is the agent instructions, invocable via `@name` or delegation. They are emitted to the goose-specific discovery dirs `.goose/agents/<name>.md` (project) and `~/.config/goose/agents/<name>.md` (global), so the output cannot collide with a future shared `.agents/agents/` target; `model` and unknown future fields round-trip through the rulesync `goose` subagent section. Earlier rulesync versions emitted subagents as sub-recipe YAML under `.goose/recipes/subagents/` — a location Goose's agent discovery never scans, so those files were inert; they are no longer generated (stale outputs stay gitignored but are not cleaned up automatically).\n\n### Vibe-specific: stdio `cwd` and MCP `[auth]` block\n\nVibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory), a structured per-server `auth` block (Vibe v2.15.0+), and the four keys Vibe's `/mcp` panel writes back when you toggle a server or one of its tools — `prompt`, `sampling_enabled`, `disabled` and `disabled_tools`. Because `mcp_servers` is replaced as a whole array on each generate, a server Rulesync writes is seeded from the on-disk entry of the same name for exactly those keys, so a toggle you made in the TUI survives — unless your `.rulesync/mcp.json` states the value itself, which wins. `disabled_tools` is the canonical `disabledTools` under Vibe's spelling; `prompt` and `sampling_enabled` have no canonical equivalent and pass through as-is. The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. Servers added outside Rulesync — through `vibe mcp add` (v2.23.0) or the `/mcp add` panel, both of which persist straight into this TOML — are preserved after the managed entries instead of being deleted by the array replace. The flip side: removing a server from `.rulesync/mcp.jsonc` no longer removes it from `config.toml`; delete it there too (or run `vibe mcp remove`). Deleting it from one scope may not be enough either: since v2.24.0 Vibe stacks the user and project layers and union-merges `mcp_servers` by name, so a server of the same name left in `~/.vibe/config.toml` still resolves after you remove it from the project file. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/models.py`). The `vibe-plugin` target instead writes a plugin bundle's `mcp.json` in the Agent Plugins shape (project scope only), with stdio and streamable-http servers only. See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **GitHub Copilot (VS Code) MCP note:** the `copilot` target writes `.vscode/mcp.json`, which has three documented top-level sections: `servers`, `inputs` (secret prompts referenced as `${input:id}`) and `sandbox` (filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces only `servers`; the rest of the document — including any future top-level section — is read back and preserved on each generate. VS Code recommends committing this file, so dropping an `inputs` entry would leave `${input:…}` unresolvable and the affected servers would fail to start. If the existing file cannot be parsed, generate fails with an error rather than overwriting it. See the [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration).\n\n> **Rovo Dev CLI MCP note:** Rovo Dev documents the per-server transport key as `transport` (`stdio` | `http` | `sse`), not the canonical `type`. Rulesync translates on the way out (`local` → `stdio`, `streamable-http` → `http`) and back on import; `ws` has no Rovo Dev equivalent, so those servers are skipped with a warning, and a `transport` value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. `disabled` is stripped from the servers that are written, since `mcp.json` is not where a Rovo Dev server is switched on and off — see the toggle handling below. `mcp.json` is written at both scopes: the global `~/.rovodev/mcp.json`, and in project mode the repo-committed `.rovodev/mcp.json` the Bitbucket Cloud Agentic Pipelines guide documents (pointed at via `mcp.mcpConfigPath`, which Rulesync now writes into the project `config.yml` for you: Rovo Dev's `mcpConfigPath` default points at the _global_ MCP file, so without the pointer the generated project `mcp.json` is never read. The pointer names one config instead of merging with the default, so it is written only when the project has a Rovo Dev server to run — one that targets `rovodev`, is not disabled, and names a `command`/`url` to start — since otherwise the project would trade the user's global servers for an empty file; writing it is logged, being the step that makes Rovo Dev start those servers. If the last such server is later removed or switched off, the pointer is not taken back out (Rulesync cannot tell its own past value from a user who typed the same string) but the now-empty result is reported with a warning. A `mcpConfigPath` already aimed somewhere else is left in place — it is the user's choice — and reported with a warning naming the file that stays unread. One value earns its own message in global scope: `~/.rovodev/mcp_config.json`, the default the settings reference documents, which Rovo Dev may therefore have written itself rather than the user choosing it. That is exactly the user this pointer exists for, so the warning says what to change and whether that file holds servers they would lose by changing it. The comparison recognizes the other spelling of the same home-directory file — an already-expanded absolute path — so a pointer that already names the generated file is not reported as unread, and a stale one still is. `$HOME/...` and `${HOME}/...` are deliberately not counted as correct: nothing in Atlassian's documentation says Rovo Dev expands environment variables in this setting, and a pointer that resolves literally reads no MCP servers at all, so that spelling gets a warning of its own asking for the `~` form instead. Global scope gets the same treatment, with the home-anchored value `~/.rovodev/mcp.json`, because Atlassian's own pages disagree about the default: the [settings reference](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) documents `~/.rovodev/mcp_config.json`, while the [MCP guide](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/) has servers registered in `~/.rovodev/mcp.json`. Under the first spelling the global file Rulesync writes is never read, so naming it explicitly is what makes the outcome the same either way; the value has to be home-anchored rather than repo-relative, since `~/.rovodev/config.yml` is read from whatever directory Rovo Dev runs in. That same disagreement is why the global pointer is **withheld** when `~/.rovodev/mcp_config.json` holds servers of its own — if the settings reference is the spelling in force, those are the servers Rovo Dev is running today, and naming `mcp.json` instead would stop them being read on every project on the machine. Rulesync does not write that file, so it could neither import them first nor put them back; it reports what it found and leaves the pointer for you to set once you have moved what you want to keep into `.rulesync/mcp.jsonc`. A `mcp_config.json` that cannot be parsed, cannot be read (a directory or a permission error at that path, which costs this one decision rather than aborting the run), or carries a shape Rulesync does not recognize all count the same way, since none of them can be shown to be empty; only an `mcpServers` map with no entries — or a literally empty `{}` — releases the pointer. Each of those reports also says what to do when the file turns out to hold nothing you need — set `mcp.mcpConfigPath` yourself, which Rulesync never overwrites, or change it if it is already set — so a file Rulesync cannot see inside is never a dead end. Writing the global pointer is a warning rather than a note, the project pointer changing one repository where this one changes Rovo Dev everywhere. The project file is not gitignored, since committing it is the point). A server the canonical config marks `disabled: true` is no longer dropped: its definition is written to `mcp.json` (minus the flag, which the file cannot express) and its name goes to `mcp.disabledMcpServers` in the sibling `config.yml` — the key Rovo Dev actually consults — where rulesync owns the toggle for the servers it manages while user keys (`allowedMcpServers`, ...) and disabled names for unmanaged servers survive. On import, names listed in `mcp.disabledMcpServers` come back as `disabled: true` on the matching servers; a `config.yml` that exists but cannot be parsed fails both directions closed (the import errors instead of silently re-enabling servers, and generate skips disabled definitions it cannot switch off). Since the project `mcp.json` is committed, prefer env-var references over literal credentials in server `env`/`headers`; note that rulesync owns the `mcpServers` map in that file, so servers hand-added there (rather than to `.rulesync/mcp.jsonc`) are replaced on the next generate. Rovo Dev's per-server `enable_instructions` — which decides whether the server's own initialization-response instructions join the agent's system prompt — is authored as the Rulesync-only key `rovodevEnableInstructions` (Rovo Dev's own `enable_instructions` spelling is accepted too, so an entry copied out of Atlassian's docs works) and written out as `enable_instructions`; on import it is lifted back onto `rovodevEnableInstructions`. It is a **tri-state**, not a switch that only exists when on: as of the [2026-09-02 revision](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/) of Atlassian's MCP page, an absent key or `true` surfaces the instructions and `false` is what suppresses them — the opposite of the opt-in wording the key was first documented under. So both booleans are written and both are lifted, and `false` above all: it is the one value that keeps a third-party server's text out of the prompt, and dropping it would leave the suppression unauthorable and make an import → generate round-trip switch it back on. Leaving the key out authors nothing, matching Rovo Dev's own default. A non-boolean is not read as a suppression in either direction — Rovo Dev surfaces the instructions for anything that is not `false` — and when both spellings are present on one entry the canonical key decides, so an explicit `rovodevEnableInstructions: false` overrides an `enable_instructions: true` left over from a copied example rather than losing to it. Generate emits no warning for this key: writing `true` now restates the upstream default rather than widening anything. Like `musecodeMode`, the key is stripped from every other target's output — and here that matters more than tidiness: it decides whether a third-party server's text joins the model's prompt, so it must not reach a tool you were not writing about. See the [Rovo Dev MCP docs](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/).\n\n> **Meta Muse Code MCP note:** Muse Code reads MCP servers only from the `mcp_servers` block of the **global** user settings file `~/.config/muse/settings.json` — no project-scoped MCP location is documented, so the `musecode` MCP target requires `--global`. Since 0.2.1 Muse Code also accepts the ecosystem-standard `mcpServers` spelling: a settings file that already uses only `mcpServers` is imported from and written back under that key, so Rulesync never adds a second MCP block beside it; a file carrying both is read and written through `mcp_servers` with a warning (which block Muse Code reads then is undocumented), leaving `mcpServers` untouched. Each server entry carries a `transport` discriminator: `stdio` servers get `command` (a single string), `args`, `env` and `cwd` (the server's working directory, honored since 1.2.1), and remote servers become `transport: \"streamable_http\"` with `url`/`headers` (Muse Code's only documented remote transport). A server that states `sse` or `ws` — or carries a `ws://`/`wss://` URL with no stated type — is skipped with a warning rather than rewritten onto a transport it does not speak. A canonical `disabled: true` maps to Muse's `enabled: false` and back. The canonical millisecond timeouts become Muse Code's per-server seconds fields — `timeout` → `tool_timeout_sec` (tool calls, resource reads and prompt fetches) and `networkTimeout` → `startup_timeout_sec` — rounded up to whole seconds, since fractional seconds are not documented; only positive values are written. On import both are multiplied back to milliseconds, and a non-positive or non-numeric value is dropped with a warning rather than copied into other targets' configs. The settings file must carry `\"schema_version\": 1` — Muse Code fails startup with `malformed settings file` without it — so Rulesync bootstraps the key when it creates the file and preserves an existing value; every other settings key is preserved on round-trip and the file is never deleted. There are no per-server tool allow/deny lists. Muse Code's per-server `mode` — `required` (its default) aborts the whole run when the server fails to start, `optional` skips it with a warning — is authored as the Rulesync-only key `musecodeMode` and written out as `mode`; on import a `mode` of `required` or `optional` is lifted back into `musecodeMode`, while any other value is dropped with a warning naming the server — keeping it would copy a Muse Code key into every _other_ target's generated config, since Rulesync's server schema is loose, and the next generate would drop it from the Muse Code side regardless. `musecodeMode` is stripped from every other target's output, so it is safe to leave on a shared server entry. See the [Muse Code extending docs](https://dev.meta.ai/docs/muse-code/extending.md) and [configuration docs](https://dev.meta.ai/docs/muse-code/configuration.md).\n\n> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`; `sse`, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto `http` pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. The canonical `disabledTools` list is written as the v2 line's per-server `disabled_tools` (raw `tools/list` names the server's tools are filtered by) and read back on import; Reasonix has no per-server tool allowlist, so `enabledTools` is not emitted. Blank tool names are dropped with a warning on generate, and on import only the string entries of the list are kept. The legacy v1 line has no such key and ignores it on load. The `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with `default_tools_approval_mode`, `tools.<raw>.approval_mode` and `approvals_reviewer` — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical `mcpServers` that every MCP target writes out, so it would surface in `.mcp.json` and the rest. Note that Rulesync owns the `plugins` key, so the next generate drops the key from an older `reasonix.toml` as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (`ws`, including a `ws://`/`wss://` URL that states no transport at all) is skipped with a warning rather than written as a `type` its loader rejects. Each entry also supports `startup_timeout_seconds` (a per-server cap on the background launch/authorization/`initialize`/`tools/list` sequence, overriding the global `mcp_startup_timeout_seconds`; `0` means defer to that global cap, and is preserved rather than dropped), `call_timeout_seconds` (a per-server MCP call timeout), `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name), `concurrency` (`serial` stops two calls to that server from running at once; with no value Reasonix substring-matches the server name against a known-stateful list — `browser`, `playwright`, `puppeteer`, `chrome`, `chromium`, `selenium` — and serializes a match, so an explicit value is what settles the question in either direction: `serial` for a stateful server the list does not catch, `parallel` for one it does), `auto_start` (`false` switches the server off rather than deferring it: Reasonix drops it from the enabled set, so its tools never reach the model and it is reported as `disabled` rather than `deferred`, and only an explicit re-enable — recorded in `mcp-activation.json`, which outranks the file — brings it back), `load` (v2: `always` puts the server's tools in the schema from session start; empty or `deferred` reaches them through `use_capability`) and `oauth_allow_missing_pkce_metadata` (v2: lets OAuth proceed with an authorization server that does not advertise PKCE methods; Reasonix honours it only from the user config, so it has no effect in a project `reasonix.toml`, and rulesync names it in a warning whenever it writes `true`). All of them round-trip as passthrough fields on the canonical MCP server object rather than through a deep mapping, except `auto_start`, which canonical `disabled` drives. A canonical `disabled: true` is always written as `auto_start = false` — a stop asked for across every tool wins over an authored `auto_start`, with a warning when that was anything but `false` — and an authored `auto_start` applies only to a server that is not disabled. On import, `auto_start = false` becomes canonical `disabled: true` and `auto_start` itself is not kept (`true` is the default), so a later edit to `disabled` is not shadowed by a leftover value. `startup_timeout_seconds`, `call_timeout_seconds`, `tool_timeout_seconds`, `concurrency`, `auto_start`, `load` and `oauth_allow_missing_pkce_metadata` are type-checked on generate. A value of the wrong type would make Reasonix fail to load the whole config file, so it is dropped with a warning instead of being written. The two scalar timeouts must be integers and `tool_timeout_seconds` a table of integers, because Reasonix decodes them into `int` and a fractional number would be written as a TOML float. A `concurrency` string other than `serial` / `parallel` (compared case-insensitively, as Reasonix does) is dropped with a warning too, since Reasonix ignores it and falls back to the server-name default. `concurrency`, `call_timeout_seconds`, `tool_timeout_seconds`, `load` and the OAuth key have no canonical counterpart at all; the activation store can still flip a server independently of the file, which is runtime state rather than config; and `startup_timeout_seconds` has a near-equivalent in canonical `networkTimeout` (which Codex CLI deep-maps to its `startup_timeout_sec`), but canonical timeouts are milliseconds while Reasonix takes seconds, and Reasonix's meaningful `0` has no canonical spelling — so mapping it would either invent a value or lose one. Vibe's `startup_timeout_sec` passes through for the same reason. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema).\n\n> **ZCode MCP note:** ZCode reads MCP servers from the `mcp.servers` block of its own JSON config: `<project>/.zcode/config.json` (project) and `~/.zcode/cli/config.json` (global, via `--global`). A stdio server carries `command`, `args` and `env`; a remote server carries `type` — `http` (what rulesync calls `streamable-http`) or `sse` — plus `url` and optional `headers`. ZCode's own page shows a JSON example for stdio servers only and describes remote ones through its UI (\"Service URL\" plus optional headers), so the remote JSON spelling used here is inferred from the configuration Z.ai documents for the other MCP clients it supports. A server with no stated transport and an `http(s)` URL is written as `type: \"http\"`, ZCode's default remote transport, while a `ws`/`wss` server is skipped with a warning rather than rewritten onto a transport ZCode does not implement. ZCode's per-server toggle is `enable`, which defaults to `true` when absent, so a canonical `disabled: true` is written as `enable: false` and lifted back on import. The file is shared ZCode config: rulesync only replaces the `mcp` key (preserving its non-`servers` siblings and every other top-level key such as `model`), never deletes the file, and fails closed rather than overwriting a `config.json` it cannot parse. ZCode's legacy `.agents/mcp.json` fallback — consulted only while the `.zcode` file of the same scope lists no server — is neither written nor imported, so a hand-maintained one is left alone. Because rulesync replaces the whole `servers` map, a server added to `config.json` by hand is dropped on the next `generate`; add it to `.rulesync/.mcp.json` instead. The project file is committable, so prefer `env` entries that reference environment variables over literal credentials. There are no per-server tool allow/deny lists. See the [ZCode MCP services docs](https://zcode.z.ai/en/docs/mcp-services). The `zcode-plugin` target writes the same server shape into a plugin bundle's `.mcp.json` under `mcpServers` (project scope only), with a disabled server written as `enabled: false`, the plugin loader's spelling. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **IBM Bob MCP note:** IBM Bob reads MCP servers from the `mcpServers` map of a dedicated file: `<project>/.bob/mcp.json` (project) / `~/.bob/settings/mcp.json` (global, via `--global`); the project entry wins when both define the same server name. Rulesync writes the shape Bob IDE documents: a stdio server carries `command` and `args`; a streamable-HTTP server (canonical `http`, `streamable-http`, a bare `url`, or Claude-style `httpUrl`) is written as `type: \"streamable-http\"` plus `url`; an explicit `sse` server is written as a bare `url` with no `type`, Bob's legacy SSE spelling. `env`, `cwd`, `headers`, `timeout`, `alwaysAllow`, and `disabled` are passed through. The canonical `oauth` object is flattened onto the keys Bob reads: `oauth: true` plus `clientId`, `clientSecret` and `scope` (a flat key already on the server wins over the nested one; nested keys Bob has no counterpart for, such as `callbackPort`, are dropped), while a boolean `oauth` passes through as Bob's own on/off switch. On import the flat keys are gathered back into the `oauth` object when `oauth` is `true` or absent; `oauth: false` leaves the entry as authored. A server Bob cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. On import, Bob IDE's `type: \"streamable-http\"`/`sse` entries pass through, a bare `url` becomes `type: \"sse\"`, and Bob Shell's `httpURL` spelling becomes `url` with `type: \"http\"` (Bob Shell's own user file, `~/.bob/mcp_settings.json`, is neither written nor imported). Only the `mcpServers` key is rewritten, so other keys in an existing file are preserved. The global `~/.bob/settings/mcp.json` is created and edited by Bob IDE's own MCP settings UI and is therefore never removed by `--delete`. See the [Bob IDE MCP docs](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob), the [Bob MCP OAuth docs](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-oauth) and the [Bob Shell MCP docs](https://bob.ibm.com/docs/shell/configuration/mcp/mcp-bobshell).\n\n> **Tabnine CLI MCP note:** Tabnine CLI reads MCP servers from the `mcpServers` key of the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global, via `--global`). A stdio server carries `command` and `args`; a remote server carries `url` plus an optional `type` — `\"http\"` (streamable HTTP; the canonical `streamable-http` alias and the Claude-style `httpUrl` field are folded into it) or `\"sse\"`. A bare `url` with no stated transport is written without `type`, since Tabnine then tries streamable HTTP first and falls back to SSE on its own. Rulesync maps the canonical per-server `enabledTools` ⇄ Tabnine's `includeTools` and `disabledTools` ⇄ `excludeTools`; `env`, `cwd`, `headers`, `timeout` and `trust` pass through. A server Tabnine cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so the other settings in the file are preserved, and the file is never removed by `--delete`. See the [MCP server config docs](https://docs.tabnine.com/main/getting-started/tabnine-agent/mcp-intro-and-setup/mcp-server-config).\n\n> **Snowflake Cortex Code MCP note:** Snowflake Cortex Code reads MCP servers only from the `mcpServers` map of the user-level `~/.snowflake/cortex/mcp.json` — no project-scoped MCP file is documented, so the `mcp` feature is **global-only** (`--global` is required; a project-scope run skips the target with a warning). Every server is written with an explicit `type`: a stdio server carries `type: \"stdio\"`, `command` and `args`; a remote server carries `url` plus `type: \"http\"` (the canonical `http`, `streamable-http`, a bare `url`, and the Claude-style `httpUrl` field all fold into it) or `type: \"sse\"`. `env`, `headers`, `oauth` and `timeout` pass through. A server Cortex Code cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so other keys in an existing file are preserved, and because `cortex mcp add` edits the same file it is never removed by `--delete`. See the [MCP configuration docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#mcp-configuration).\n\n> **Codewhale MCP note:** Codewhale reads MCP servers from the `servers` map (the `mcpServers` alias is also accepted) of `~/.codewhale/mcp.json` (global) and `.codewhale/mcp.json` (project). The project file is honored only once the workspace is trusted, and its servers override same-named user servers. Stdio servers carry `command` and `args`; remote servers carry `url` and are reached over Streamable HTTP, with `transport: \"sse\"` written only for servers declared as SSE. WebSocket servers are skipped with a warning. `enabledTools` / `disabledTools` become `enabled_tools` / `disabled_tools`. The canonical `timeout` and `networkTimeout` (milliseconds) become `execute_timeout` (the budget for each tool call) and `connect_timeout` (spawn, `initialize` and the first `tools/list`), which Codewhale reads as whole seconds: the value is rounded up to the next second, a negative or zero value is dropped with a warning (Codewhale has no \"no timeout\" value and fails at once on a zero budget), and an explicit `execute_timeout` / `connect_timeout` on the same server wins. Import multiplies them back into milliseconds; `read_timeout` has no canonical counterpart and passes through unchanged. Other top-level keys (such as `timeouts`) and whichever server-map key the file already uses are preserved. Because Codewhale's own `/mcp` commands also edit the file, `--delete` never removes it.\n\n> **Continue MCP note:** Continue reads every `*.json` file under `.continue/mcpServers/` (project) / `~/.continue/mcpServers/` (global, via `--global`); Rulesync writes the documented `mcp.json` there as plain JSON with an `mcpServers` map (an existing file is read as JSONC), and other files in the directory are untouched. A stdio server carries `type: \"stdio\"`, `command`, `args` and the optional `env` (`envFile` is dropped with a warning because Continue ignores it, and `cwd` and `timeout` are dropped with a warning because Continue's JSON schema has no place for them); a remote server carries `url` plus `type: \"sse\"` or `type: \"http\"` (the canonical `streamable-http` alias and a bare `url` both fold into `http`), with `headers` passed through (`oauth` and `timeout` are dropped with a warning for the same reason). A server Continue cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so other top-level keys in an existing `mcp.json` are preserved. The project file is removed by `--delete`; the global one, which `~/.continue/` shares with hand-managed servers, is not. See the [MCP docs](https://docs.continue.dev/customize/deep-dives/mcp).\n\n> **CodeBuddy Code MCP note:** CodeBuddy Code reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope — the same file Claude Code reads) and of `~/.codebuddy/.mcp.json` (global, via `--global`). Those are the recommended file names; the deprecated `mcp.json` spellings and the legacy `~/.codebuddy.json` are only read by CodeBuddy when the recommended file is absent, and the per-project local scope kept in `~/.codebuddy.json` is left to the tool. CodeBuddy's server keys (`type` — `stdio`, `sse` or `http` —, `command`, `args`, `env`, `url`, `headers`, `description`) are canonical, so both scopes are written in Claude Code's pass-through shape (only rulesync-only fields are stripped) and `--targets claudecode,codebuddy` yields the same project `.mcp.json` bytes whichever target generates last (in project mode both apply the `claudecode`, `codebuddy`, `commandcode` and `qoder` blocks, see the alias groups above). Only the `mcpServers` key is rewritten, so other top-level keys such as `disabledMcpServers` are preserved (an existing file is read as JSONC); the canonical `disabled` flag is passed through as written rather than translated into `disabledMcpServers`. The project file is removed by `--delete`; the global one is not. See the [MCP docs](https://www.codebuddy.ai/docs/cli/mcp).\n\n> **Command Code MCP note:** Command Code reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope — the same file Claude Code reads, meant to be committed) and of `~/.commandcode/mcp.json` (global, via `--global`); the per-machine local scope under `~/.commandcode/projects/` is left to the tool. Because the project file is the one the `claudecode` target writes, project mode writes it in exactly Claude Code's pass-through shape — the canonical servers as they are, only rulesync-only fields stripped — so `--targets claudecode,commandcode` yields the same bytes whichever target generates last (and both apply both the `claudecode` and `commandcode` blocks, see the alias groups above). Command Code reads that shape natively: `type` is an alias of `transport`, a bare `url` means `http`, and keys it does not know (`disabled`, `timeout`) are ignored as they are by Claude Code. Only the global file, which is Command Code's own, is written in its documented spelling: every server carries the `transport` key — a stdio server `transport: \"stdio\"`, `command`, `args` and the optional `env`; a remote server `url` plus `transport: \"http\"` (the canonical `streamable-http` alias and a bare `url` both fold into `http`) or `transport: \"sse\"`, with `headers`, `env` and `oauth` passed through. There a server marked `disabled: true` is written with Command Code's native `enabled: false` and read back as `disabled: true`, other keys (`timeout`, rulesync-only fields) are left out, and a server Command Code drops at load time — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a form the tool would silently ignore. At both scopes only the `mcpServers` key is rewritten, so other top-level keys in an existing file are preserved (an existing file is read as JSONC). The project file is removed by `--delete`; the global one, which `~/.commandcode/` shares with hand-managed servers, is not. See the [MCP docs](https://commandcode.ai/docs/mcp).\n\n> **Qoder MCP note:** Qoder reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope, the file its docs describe as usually committed — the same one the `claudecode` and `commandcode` targets write) and of `~/.qoder/settings.json` (global, via `--global`); the per-machine `.qoder/settings.local.json` is left to the user. Qoder uses the same server keys as rulesync (`command`, `args`, `env`, `type`, `url`, `headers`), so servers are written as they are, with only rulesync-only fields stripped; in project mode the output is byte-identical to the `claudecode` target's, and all four targets (`claudecode`, `claudecode-legacy`, `commandcode` and `qoder`) apply the `claudecode`, `commandcode` and `qoder` blocks (see the alias groups above). Other top-level keys are kept: in `~/.qoder/settings.json` only `mcpServers` is rewritten, a file that fails to parse is an error rather than being replaced, and that file is never removed by `--delete`, while the project `.mcp.json` is. See the [MCP docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/mcp-servers).\n\n> **Crush MCP note:** Crush reads MCP servers from the `mcp` key of its shared JSON config: `<project>/crush.json` (project) / `~/.config/crush/crush.json` (global, via `--global`). Crush's schema requires a `type` on every server, so one is always written: a stdio server carries `type: \"stdio\"`, `command`, `args` and `env`; a remote server carries `url` plus `type: \"http\"` (the canonical `http`, `streamable-http`, a bare `url`, and the Claude-style `httpUrl` field all fold into it) or `type: \"sse\"`, with `headers` passed through. The canonical per-server `disabledTools` ⇄ Crush's `disabled_tools` and `enabledTools` ⇄ `enabled_tools` (both match the server's own tool names, without the `mcp_<server>_` prefix Crush uses in its permissions list); `disabled` and `timeout` (which Crush reads as an integer number of **seconds**, so it is forwarded as authored and a fractional value is rounded up) map onto the same-named fields, and Crush's OAuth fields and `sessionless` marker are authored under the Crush-only keys `crushOauth`, `crushOauthClientId`, `crushOauthClientSecret`, `crushOauthCallbackPort` and `crushSessionless` (written as `oauth`, `oauth_client_id`, `oauth_client_secret`, `oauth_callback_port` and `sessionless`; the raw `oauth_client_id`, `oauth_client_secret`, `oauth_callback_port` and `sessionless` spellings are honoured as a fallback for an entry copied out of a `crush.json`, but only `crushOauth` turns the flow on, because the canonical `oauth` key is Claude Code's `{ clientId }` object and a raw `oauth: true` would be forwarded to other tools as well). Like Codex's `envVars`, these keys are stripped from what every other tool sees, so the client secret never lands in another tool's config. A server Crush cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form; an existing `oauth_token`, which Crush persists at runtime, is never imported. Note that `headers` and `oauth_client_secret` (as `crushOauthClientSecret`) are copied verbatim into `.rulesync/mcp.json` on import, so move any literal secret into an environment-variable reference (`$VAR`) before committing that file. At project scope Crush reads both `.crush.json` and `crush.json` and merges them (objects recursively, lists concatenated, `.crush.json` on top), so neither file hides the other. When a `.crush.json` already exists it is the file written (otherwise `crush.json`); if the `crush.json` next to it still carries the keys rulesync owns — for example from a generate run before `.crush.json` was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only `~/.config/crush/crush.json` is read. Only the `mcp` key is rewritten, so the `providers`, `options`, `permissions` and `hooks` keys in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. The JSON config is documented as deprecated in favour of the Bash-based `crushrc`, but it stays supported and is the schema Crush publishes; a `crushrc` next to it compiles `mcp add` into the same `mcp.<name>` entries and wins key by key. See the [config docs](https://github.com/charmbracelet/crush/blob/main/docs/config/README.md#mcp-servers).\n\n> **Codebuff (Freebuff) MCP note:** Codebuff — renamed to Freebuff; the `codebuff` and `freebuff` CLIs share one configuration — reads MCP servers from the `mcpServers` map of `.agents/mcp.json` (project) / `~/.agents/mcp.json` (global, via `--global`). Codebuff validates every server against a strict schema and ignores the whole file when one server fails, so only the keys it accepts are written: a stdio server as `type: \"stdio\"`, `command` (an array `command` is split into `command` and leading `args`), `args` and `env`; a remote server as `type` (`http`, or `sse`; `streamable-http` becomes `http`), `url` (`httpUrl` is folded into it), `headers` and `params`. Other keys (`cwd`, `timeout`, tool filters, ...) are dropped with a warning, and a `disabled: true` or WebSocket (`ws`) server is skipped with a warning. Codebuff treats every `env` value that starts with `$` as a variable name and ignores the whole file when that variable is unset, so a whole-value canonical `${NAME}` is written as `$NAME` (and converted back on import), a server with any other `env` value starting with `$` (such as `${HOST}:8080` or `${VAR:-default}`) is skipped with a warning, and a reference later in a value, or in `headers`, is left as literal text. A `params` map whose values are not all strings is dropped with a warning. The global `~/.agents/mcp.json` is never deleted by `--delete`, since `~/.agents/` is shared with other tools. Codebuff asks before loading a repository's `.agents/` directory for the first time (`CODEBUFF_TRUST_AGENT_DIRS=1` or `--trust-agents` skips the prompt, e.g. in CI). Codebuff also reads `AGENTS.md` / `CLAUDE.md` and `.agents/skills/`, so use the `agentsmd` and `agentsskills` targets for rules and skills; its TypeScript agent definitions under `.agents/*.ts` are not generated. See the [MCP docs](https://www.codebuff.com/docs/tips/mcp-servers).\n\n## `.rulesync/.aiignore` or `.rulesyncignore` (deprecated)\n\n> **Deprecation notice:** The `ignore` feature is deprecated in favor of the more expressive [`permissions` feature](#rulesync-permissions-jsonc). Existing ignore configurations, generation, import, conversion, and explicit `rulesync add ignore` scaffolding remain supported throughout Rulesync 14.x. Removal, if any, will be decided separately and will not occur before a future major release. `rulesync init` no longer enables or scaffolds ignore for new projects.\n\nRulesync continues to support a single legacy ignore list in either location:\n\n- `.rulesync/.aiignore` (preferred legacy location)\n- `.rulesyncignore` (older project-root location)\n\nRules and behavior:\n\n- You may use either location.\n- When both exist, Rulesync prefers `.rulesync/.aiignore` over `.rulesyncignore` when reading.\n- Explicitly running `rulesync add ignore` creates `.rulesync/.aiignore` when neither location exists.\n\nExample:\n\n```ignore\ntmp/\ncredentials/\n```\n\n### Migrating to permissions\n\nMove each ignore pattern into the `read` category of `.rulesync/permissions.jsonc` with the `deny` action:\n\n```jsonc\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {\n \"read\": {\n \"tmp/**\": \"deny\",\n \"credentials/**\": \"deny\",\n },\n },\n}\n```\n\nThis is the closest replacement for preventing an agent from reading ignored paths — except for Zed, whose read-only tools are not permission-gated at all: there the replacement is `private_files`, which the ignore feature writes, so a `read` deny rule is dropped with a warning instead. If the old policy was also intended to prevent changes, repeat the patterns under `edit` and `write`. Target tools differ in the permission categories they can represent, so review the [Supported Tools and Features](./supported-tools.md) table and the tool-specific permission notes below before removing the old ignore feature from a multi-tool project.\n\n### Where ignore patterns are written per tool\n\nMost tools get a dedicated ignore file (for example `.cursorignore`,\n`.geminiignore`, `.clineignore`). Antigravity CLI is built on the same engine\nas Gemini CLI, so it reads the project-root `.geminiignore` file. Claude Code is the exception: it does not\nread a separate ignore file, so Rulesync writes the deny list into Claude\nCode's settings file as `permissions.deny` entries (`Read(<pattern>)`).\n\nReasonix has no ignore file either, so its deny list goes into the `[permissions]` table of the shared `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`) as `Read(<pattern>)` entries — the same Claude-Code-style rule syntax the permissions feature writes there. `deny` is used rather than `[sandbox].forbid_read` because deny rules take glob specifiers and are documented as \"a hard block in every mode\", while `forbid_read` takes absolute paths with no documented glob support. The file is shared with the MCP and permissions features: only `Read(...)` deny entries are replaced, every other table and deny entry is preserved, and the file is never deleted. When the permissions feature also manages the `Read` category its explicit rules win, and the overwrite is warned about. As with the MCP and permissions features, the file is re-serialized on write, so hand-written comments, blank lines, and key ordering in `reasonix.toml` are not preserved.\n\nGrok CLI has no ignore file either; its only path-deny surface is a custom sandbox profile. Rulesync writes the deny list into a rulesync-owned `[profiles.rulesync]` table (`extends = \"workspace\"` plus a `deny` array) in `.grok/sandbox.toml` (project) / `~/.grok/sandbox.toml` (global, via `--global`). Grok kernel-enforces a profile's `deny` entries for reads and writes, but **only while that profile is active** — start Grok with `grok --sandbox rulesync`, set `GROK_SANDBOX=rulesync`, or set `profile = \"rulesync\"` under `[sandbox]` in your user `~/.grok/config.toml`; generating the file alone changes nothing. Patterns are translated to the glob subset Grok accepts, because Grok refuses to start on a deny glob it cannot interpret: a leading `/` is dropped (Grok would read it as an absolute filesystem path, while relative entries are anchored at the workspace), and a pattern with no other `/` gains a leading `**/` so it still matches at any depth. A glob denies only the paths it matches itself, not what lies under a matching directory, so each glob is paired with a `<glob>/**` entry for its contents, and a directory-only pattern (trailing `/`) keeps only that entry (`*.pem` → `**/*.pem` + `**/*.pem/**`, `tmp/` → `**/tmp/**`); an exact path such as `/build/` → `build` needs no pairing, since Grok denies an existing directory together with its contents. On Linux, though, Grok expands every glob to the files that exist at launch and refuses to start once that exceeds its limit (4096 matches), so an unanchored directory pattern over a large tree (`node_modules/` → `**/node_modules/**`) can stop Grok from starting; anchor such a pattern (`/node_modules/`) to get an exact, scan-free entry instead (it covers the directory's contents on both platforms when the directory exists at launch; one created later is covered only by name on macOS). Negations (`!pattern`), brace alternation, backslash escapes, empty or `.`/`..` segments, a `**` that is not a whole path segment (`**.log`), and character classes Grok rejects (a literal `]` as the first member, nested `[` such as POSIX `[[:digit:]]`, or an unclosed `[`) cannot be expressed, so those patterns are skipped with a warning. Only the `rulesync` profile is managed: your other profiles, and any extra keys you add to the `rulesync` profile itself (such as `restrict_network` or a different `extends`), are preserved; when there are no patterns an existing profile is kept with an empty `deny` list (so a session that selects it still starts) and none is created; and the file is never deleted. Import reads back only the `rulesync` profile's `deny` list, folding each `<glob>/**` entry into its pair (or into a `pattern/`) and skipping absolute entries. Three upstream caveats apply: when `~/.grok/sandbox.toml` and `.grok/sandbox.toml` both define `rulesync` differently, Grok uses the user profile and warns at startup, so a project and a global generate with different lists do not combine; on Linux a glob is expanded to the files that exist when Grok starts, so files created later are not covered (macOS enforces globs at runtime), and Grok refuses to start when that scan exceeds its depth or size limits; and the file is re-serialized on write, so comments are not preserved. See the [Grok sandbox docs](https://docs.x.ai/build/features/sandbox).\n\nKiro reads `.kiroignore` in project scope and `~/.kiro/settings/kiroignore` in user scope. The `kiro` and `kiro-ide` targets therefore support `--global` for the deprecated ignore feature, as do `grokcli`, `reasonix` and `zed` (whose config files exist in both scopes), as well as `devin` and `kilo` (below); the remaining ignore targets are project-only. `kiro-cli` is project-only too: Kiro's [capability matrix](https://kiro.dev/docs/kiroignore/) marks the global ignore file as IDE-only, and the CLI reads only the workspace `.kiroignore`.\n\nKilo Code reads the project-root `.kilocodeignore` and, in global scope, `~/.kilocode/.kilocodeignore` — note the **legacy `~/.kilocode/` directory**, not `~/.config/kilo/` where the rest of Kilo's global config lives. Kilo loads that global file on every config load and appends the project patterns after it, so the project file wins on conflict ([`ignore-migrator.ts`](https://github.com/Kilo-Org/kilocode/blob/main/packages/opencode/src/kilocode/ignore-migrator.ts)). The `kilo` target therefore also supports `--global` for the ignore feature; like every other dedicated ignore file, the global one is replaced wholesale by the patterns from `.rulesync/.aiignore`.\n\nDevin writes the project file as `.devinignore` (reading the pre-rebrand `.codeiumignore` and `.windsurfignore` as import fallbacks, in that order — the docs list the three names side by side without defining a precedence, so the order is rulesync's own choice), and in global scope writes `~/.codeium/.codeiumignore`. Three things about that global path are worth knowing: it keeps the **legacy brand spelling** — the rename to `.devinignore` covered only the project file, so no `.devinignore` variant is written or read there, not even as a fallback; it is documented in the Devin **Desktop** docs tree rather than the Devin Local (CLI) one, which is why it sits outside `~/.config/devin` where the other global Devin paths live; and it is positioned as an **enterprise** feature for enforcing ignore rules across many repositories, not as a general per-user setting. See [the Devin ignore docs](https://docs.devin.ai/desktop/context-awareness/windsurf-ignore).\n\nZed has no ignore file: its deny list is the `private_files` array inside the shared settings file — `.zed/settings.json` in project scope and `~/.config/zed/settings.json` in global scope (`%APPDATA%\\Zed\\settings.json` on Windows). `private_files` is a worktree setting, and Zed layers default → user → project, so the key is honored in the user settings file too. The array is **owned wholesale by Rulesync**: it is replaced with the patterns from `.rulesync/.aiignore` on every generation, so a pattern deleted there is retracted from the file Rulesync writes instead of surviving forever. Note that this narrows what Rulesync's own layer contributes, not the effective set: Zed's shipped default and any other settings layer still add their own patterns (see below). When no patterns remain at all, the key is removed rather than written as `[]`. `private_files` is an `ExtendingVec`: each settings layer's value is appended to the one below it (`merge_from` calls `extend_from_slice`) instead of replacing it, and Zed ships a populated default (`**/.env*`, `**/*.pem`, …). Writing the key is therefore purely additive — an empty array would neither disable Zed's secret redaction nor mean anything at all — so omitting it is how Rulesync says it contributes nothing here. Every other key in the file — including the MCP `context_servers` and permissions `agent` blocks and unrelated editor settings — is preserved, and the file is never deleted.\n\nGoose retired `.gooseignore` upstream (\"removed some time ago in favour of other ignore things like gitignore etc\" — [goose#10343](https://github.com/aaif-goose/goose/issues/10343)), so rulesync no longer generates it; the replacement guidance is `.gitignore` plus tool permissions. Stale `.gooseignore` files from earlier versions stay gitignored but are not cleaned up automatically.\n\nCline's `.clineignore` is still emitted, but its own docs now title it \"deprecate soon\" and state it is not a security or access-control boundary — upstream's replacement direction is a Cline plugin enforcing via a `beforeTool` hook. Treat the matrix ✅ as a deprecated surface.\n\nCrush writes `.crushignore` at the project root only (no global equivalent), using the same gitignore-style patterns Crush already applies to its own built-in exclusion list in [`internal/fsext/fileutil.go`](https://github.com/charmbracelet/crush/blob/main/internal/fsext/fileutil.go).\n\nIBM Bob reads a gitignore-style `.bobignore` at the project root only (there is no user-level equivalent, so `--global` is not supported), which keeps the listed files out of Bob's context and file operations. See the [bobignore docs](https://bob.ibm.com/docs/ide/configuration/bobignore).\n\nTabnine CLI reads a gitignore-style `.tabnineignore` at the project root only (there is no user-level equivalent, so `--global` is not supported), which the CLI's file search, `@` references and directory-listing tools respect alongside `.gitignore` (the `context.fileFiltering.*` settings control this). See the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference).\n\nContinue reads a gitignore-style `.continueignore` at the workspace root and a user-level `~/.continue/.continueignore` (global, via `--global`), which keep the listed files out of codebase indexing and the `@` context providers alongside `.gitignore`. See the [codebase reference](https://docs.continue.dev/reference/deprecated-codebase).\n\nLetta Code reads `.letta/.lettaignore` at the project root only (there is no user-level equivalent, so `--global` is not supported). It only keeps the listed files out of Letta Code's `@` file search — it is not an access-control boundary, so use the `permissions` feature to deny reads. The patterns are written as-is; note that Letta Code skips `!` negation lines, strips a trailing `/`, matches a pattern containing `/` against the relative path, and matches any other pattern case-insensitively against the file or directory name. See the [settings docs](https://docs.letta.com/reference/settings/index.md).\n\nQoder reads a gitignore-style `.qoderignore` at the project root only (there is no user-level equivalent, so `--global` is not supported). Qoder documents it for the Qoder IDE, which keeps the listed files out of codebase indexing alongside `.gitignore`; it is not an access-control boundary, so use the `permissions` feature to deny reads. See the [codebase indexing docs](https://docs.qoder.com/user-guide/indexing).\n\nCodebuff (Freebuff) reads a gitignore-style `.codebuffignore` at the project root beside `.gitignore` (there is no user-level equivalent, so `--global` is not supported), which keeps the listed files out of the agent's file tree. The file keeps its Codebuff name after the rename to Freebuff, and both the `codebuff` and `freebuff` CLIs read it. The patterns are written as-is, `!` negation included. See the [source](https://github.com/CodebuffAI/freebuff/blob/main/common/src/util/project-ignore.ts).\n\nHermes Agent uses a project-local `rulesync-ignore` plugin under `.hermes/plugins/`. It applies the canonical gitignore-style patterns through [`pre_tool_call`](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/#pre-tool-call) to `read_file`, `write_file`, and `patch` before execution, and filters ignored paths from `search_files` results through `transform_tool_result`. This is defense in depth around Hermes file tools; terminal commands and paths already present in conversation context are outside the plugin's enforcement surface. Hermes deliberately requires [explicit trust for project plugins](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins/), so run it from the trusted project root with that invocation opted in:\n\n```sh\nHERMES_ENABLE_PROJECT_PLUGINS=1 hermes\n```\n\nRulesync adds `rulesync-ignore` to `plugins.enabled` in `$HERMES_HOME/config.yaml` but deliberately leaves `$HERMES_HOME/.env` unchanged. Existing configuration is preserved, explicit `plugins.disabled` conflicts fail, and `--delete` retains the additive user-level activation.\n\nFor Cursor, Rulesync emits only `.cursorignore` — the file that **blocks access\nentirely** (semantic search, Tab, Agent, Inline Edit, and `@`-mentions). Cursor\nalso supports a second file, `.cursorindexingignore`, which excludes files from\n**indexing only** while keeping them accessible to the AI on demand. These two\nfiles mean _different_ things, and Rulesync's `ignore` feature models a single\ncanonical ignore list per tool with no per-pattern distinction between\n\"block access\" and \"exclude from indexing only\". Emitting the same patterns to\nboth files would be incorrect, so `.cursorindexingignore` is intentionally **not\ngenerated** (an intentional non-goal). Author it by hand if you need\nindexing-only excludes.\n\nBy default, Claude Code's deny list is written to the **shared**\n`.claude/settings.json` so that the policy can be committed and reviewed by\nthe team. This is intentional (see issue #1094), but it means that running\n`rulesync gitignore` will not add `.claude/settings.json` to `.gitignore` —\nthat file may also contain other shared Claude config you actively want to\ncommit.\n\nIf you would rather keep the deny list out of version control, opt into the\n**local** mode using the per-feature options object form:\n\n```jsonc\n// rulesync.jsonc\n{\n \"targets\": [\"claudecode\"],\n \"features\": {\n \"claudecode\": {\n \"ignore\": { \"fileMode\": \"local\" },\n },\n },\n}\n```\n\n| `fileMode` | Output file | Tracked by git by default |\n| -------------------- | ----------------------------- | ----------------------------------------------------- |\n| `\"shared\"` (default) | `.claude/settings.json` | Yes — meant to be committed and shared with the team. |\n| `\"local\"` | `.claude/settings.local.json` | No — `rulesync gitignore` already excludes this file. |\n\nEither file gets a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as its first entry when it does not have one yet; an existing `$schema` is kept as is.\n\n## `.rulesync/permissions.jsonc`\n\n`.rulesync/permissions.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/permissions.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nFor Hermes Agent imports, Rulesync treats a valid private `permissions.rulesync` block as provenance, then reconciles it with current native settings. `command_allowlist`, `approvals.deny`, and an enabled `security.website_blocklist` are authoritative for their mapped canonical rules (`bash` `allow`, `bash` `deny`, and `webfetch` `deny` respectively), so hand edits replace stale generated values; an `allow` in any other category, and a `bash` `allow` that generation withheld because a stricter `*` or `bash` rule covers it, are kept as the private block recorded them, since the allowlist never carried them. A config with no private block still imports those native rules. Unmodeled `approvals`, `security`, `skills`, and `memory` settings remain under the `hermes` override; unrelated root settings such as `model` are not imported.\n\n`rulesync init` scaffolds a `codexcli` block with `approval_policy: \"on-request\"`, `approvals_reviewer: \"auto_review\"`, and `base_permission_profile: \":danger-full-access\"`. On generation, the profile value becomes Codex's top-level `default_permissions`.\n\nPermissions define which tool actions are allowed, require confirmation, or are denied. The canonical format uses **lowercase tool category names** and **glob patterns** mapped to permission actions.\n\n**Permission actions:**\n\n- `allow` -- Automatically permitted without user confirmation\n- `ask` -- Requires user confirmation before execution\n- `deny` -- Blocked from execution\n\n**Supported tool categories:** `bash`, `read`, `edit`, `write`, `webfetch`, `websearch`, `grep`, `glob`, `notebookedit`, `agent`, the all-tools key `*` (a rule written under it is meant for every tool, shell commands included, so a `*` `deny` overrides a `bash` `ask` or `allow` on the same pattern, following `deny` > `ask` > `allow`; how faithfully each adapter carries it is described per tool below), and MCP-specific tool names (e.g., `mcp__puppeteer__puppeteer_navigate`)\n\nExample:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {\n \"bash\": {\n \"git *\": \"allow\",\n \"npm run *\": \"allow\",\n \"rm -rf *\": \"deny\",\n \"*\": \"ask\"\n },\n \"edit\": {\n \"src/**\": \"allow\"\n },\n \"read\": {\n \".env\": \"deny\",\n \"credentials/**\": \"deny\"\n }\n }\n}\n```\n\n**Rejected keys.** Two kinds of key are refused when the source file is read, rather than being carried into generated configs:\n\n- A **blank key** (empty, or only whitespace), whether it names a pattern or a category. A blank pattern is a prefix of every command and a substring of every path, so a tool that honors it grants or denies everything, while a tool that filters it — Roo Code keeps only entries passing `cmd.trim().length > 0` — ignores it entirely. A blank **category** is worse still: every target reads categories by name, so nothing under it reaches any tool and the rules written there are silently dead. Rather than let each target decide, Rulesync rejects both on the source file.\n- A key named `__proto__`, `constructor`, or `prototype`. Rulesync strips these from every source document it parses, because assigning them would reach the prototype chain instead of the object, so such an entry could never reach a generated file. Rulesync names the offending path instead of dropping it in silence — in `.rulesync/permissions.jsonc`, `.rulesync/mcp.jsonc`, and `.rulesync/hooks.jsonc` alike.\n\nImporting removes a blank key rather than reproducing it: keeping one would write a source file the very next `generate` refuses — and that refusal is fatal for the whole file, so a single blank key carried over would take every tool's permissions with it. This applies to blank patterns and blank categories, in the shared `permission` block and in tool-scoped `{toolname}.permission` blocks alike, since importing OpenCode or Kilo routes their tool-only categories into the tool-scoped block. The one exception is the _patterns_ inside `vibe.permission`, whose categories hold `sensitive_patterns` objects rather than pattern maps, so the keys inside them are field names and are left alone; its category names are filtered like any other. Removals are reported in warnings naming the tool config they were read from and how many keys were dropped from each block — patterns and categories reported separately, because dropping a pattern can widen what the imported configuration allows (a blanket blank pattern may have been the only entry denying anything) while dropping a category only removes rules that were never going to be generated.\n\nWhen removing a blank pattern leaves a category with no rules at all, the category itself is removed rather than left as an empty object. An empty category means \"Rulesync manages this category and it has no rules\", which makes the next `generate` delete the entries the tool's own config already had; removing the category leaves them alone. When that empties a tool-scoped block outright, the `permission` key is dropped too — and with it the whole `{toolname}` block if nothing else was authored under it — so no empty override is left behind for the next reader to puzzle over. In a tool-scoped block the removal also changes what the block means, from \"override this category with nothing\" to \"inherit the shared block\"; that is deliberate, since the alternative is the next `generate` deleting rules written by hand in the tool's own config, and every removal is warned about.\n\n**Importing merges into the existing file.** `rulesync import --features permissions` merges what it imports into an existing `.rulesync/permissions.jsonc` (or legacy `.rulesync/permissions.json`) **per category** instead of replacing the file, so importing from a second tool keeps what the first import produced. Each tool maps only some categories — Roo Code, for instance, maps shell commands to `bash` and nothing else — so importing from Roo Code after Claude Code replaces `bash` and keeps `read`, `edit`, `webfetch`, and the rest:\n\n- A category the import carries replaces the existing category wholesale: the imported tool is authoritative for the categories it maps, so patterns are never mixed across tools and a re-import from the same tool reproduces the same category. There is no pattern-level conflict to resolve; the last import of a category wins.\n- A category the import does not carry is kept, and so is every other top-level key (`$schema`, other tools' `{toolname}` blocks).\n- An imported category with no rules never replaces an existing one, since an empty category would make the next `generate` delete the rules another tool's import produced.\n- The importing tool's own `{toolname}` block (for example `claudecode` when importing from Claude Code) is replaced wholesale, and removed when the import produces none, so a setting removed from the tool's own config disappears here too. Hand-written tool-scoped categories in that block are therefore not kept across a re-import from the same tool. Another tool's block that the import carries — Hermes Agent restores them from its provenance block — merges field by field, and its `permission` record merges per category.\n- Comments in the existing JSONC file are kept wherever the edit allows. An existing file that fails validation stops the import instead of being overwritten.\n\nBecause the file on disk is the merge base, a category deleted by hand comes back only when the next import carries it again; delete it from the tool's own config too to keep it gone. The reverse holds as well: emptying a category in the tool's own config (removing every `WebFetch(...)` rule from Claude Code, say) does not remove the shared category, because the import no longer carries it and other tools may have contributed it — delete it from `.rulesync/permissions.jsonc` too. Hermes Agent is the exception to \"the tool is authoritative only for what it maps\": its import restores the whole canonical document recorded in its provenance block, so every category it recorded is replaced with the recorded value. Only the permissions import merges this way; the other features' imports are unchanged.\n\n### Tool-scoped permission blocks (`{toolname}.permission`)\n\nThe shared `permission` block applies to every targeted tool. To scope rules to a single tool, add a tool-scoped `{toolname}` block with a `permission` record of the same shape — mirroring `{toolname}.hooks` in `.rulesync/hooks.jsonc` and `{toolname}.mcpServers` in `.rulesync/mcp.jsonc`:\n\n```jsonc\n{\n \"permission\": {\n \"bash\": { \"git *\": \"allow\", \"*\": \"ask\" },\n },\n \"claudecode\": {\n \"permission\": {\n // Replaces the shared `bash` category for Claude Code only.\n \"bash\": { \"git *\": \"allow\", \"git push *\": \"deny\", \"*\": \"ask\" },\n },\n },\n}\n```\n\n- Categories are merged **per category**: a tool-scoped category replaces the shared category wholesale for that tool; shared categories it does not name still apply.\n- Any permissions-capable `--targets` name is accepted as a block key. `kiro-cli`/`kiro-ide` alias to the `kiro` key and `hermesagent` to `hermes` (matching the shared output file each writes).\n- OpenCode, Kilo, and Vibe keep their existing tool-native `permission` override semantics (bare action strings / tool-only categories / `sensitive_patterns` — see the tool-specific callouts below); their blocks are consumed by their translators instead of the central merge.\n\n#### JSON Schema Support\n\nRulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/permissions.jsonc`:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {}\n}\n```\n\nFor Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` (project mode) or `~/.claude/settings.json` (global mode) using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`). The generated settings file also gets a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as its first entry when it does not have one yet (an existing `$schema` is kept as is), so editors validate and autocomplete it.\n\nClaude Code's file permission checks match only `Edit(path)` and `Read(path)` rules: a `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule \"is accepted but never matched by those checks, so Claude Code warns at startup for each allow, deny, or ask rule in one of these unmatched forms\" ([permissions docs](https://code.claude.com/docs/en/permissions), v2.1.210+). Rulesync therefore writes a canonical `write` or `notebookedit` rule that carries a pattern as `Edit(pattern)`, and a `glob` rule as `Read(pattern)`. A rule whose pattern is `*` is a tool-name rule with no path — it matches the tool everywhere and produces no warning — so it is still written as the bare `Write` / `NotebookEdit` / `Glob`. Entries an earlier Rulesync wrote in the warned form are replaced on the next generate, and so is a rewritten entry whose action changed, so flipping a rule from deny to allow never leaves the old deny behind to win. Rewriting a rule does **not** make Rulesync claim the `Edit` or `Read` namespace as a whole: a `Read(...)` deny the [ignore feature](#rulesyncignore) wrote, or an `Edit(...)` rule you added to `settings.json` by hand, is left alone unless the canonical config manages that category itself. Import stays tolerant of both forms, so an existing `settings.json` still round-trips; a rewritten rule comes back under `edit` or `read` rather than the category it was authored in, since that is the rule Claude Code actually applies. Note that this widens a `glob` **allow** rule: `Read(pattern)` permits reading the files' contents, not just listing their names — the docs prescribe the substitution, but author `glob` allow rules with that in mind. When two categories resolve to the same entry with different actions (`edit` allowing what `write` denies, say) both are written and Rulesync warns — Claude Code applies deny first, then ask, then allow. A rule under the all-tools `*` category has no Claude Code counterpart: a rule there names one tool, and no name stands for every tool, so `{\"*\": {\"rm *\": \"deny\"}}` is written as `*(rm *)` — an entry that carries the rule back on import but matches no tool while Claude Code runs. Rulesync warns about every such rule, since an inert deny beside a `Bash` allow reads as a restriction that is not there; write the rule under the categories it is meant for (`bash`, `read`, ...) to have Claude Code enforce it.\n\n> **Claude Code-only override (`claudecode` key):** Claude Code's `permissions` object also carries non-list fields with no canonical permission category — notably `defaultMode` (the session-start permission mode: `default` | `acceptEdits` | `plan` | `auto` | `bypassPermissions`) and `additionalDirectories` (extra working directories). Claude Code ignores a `defaultMode` of `auto` or `bypassPermissions` in `.claude/settings.json` and `.claude/settings.local.json` ([permission modes](https://code.claude.com/docs/en/permission-modes#which-mode-a-session-starts-in)), so at project scope Rulesync skips those two values with a warning and writes them only under `--global`. Add a tool-scoped `claudecode` override key alongside the shared block to author them: the fields under `claudecode.permissions` are merged into the settings `permissions` object and emitted **only** for Claude Code, while the shared `permission` block continues to drive the managed `allow`/`ask`/`deny` arrays. The block is a verbatim passthrough (so other/future `permissions` fields such as the org locks `disableBypassPermissionsMode`/`disableAutoMode` can be set too), but any `allow`/`ask`/`deny` placed inside it is ignored — rulesync owns those arrays. On import, the non-list `permissions` fields round-trip back into the `claudecode` override. Note that these fields are merged **additively** into the existing `settings.json` (so hand-added settings survive): removing a field from the `claudecode` override does not delete a value already written to `settings.json` — clear it there by hand.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"claudecode\": {\n> \"permissions\": { \"defaultMode\": \"acceptEdits\", \"additionalDirectories\": [\"../shared\"] },\n> \"sandbox\": { \"network\": { \"allowedDomains\": [\"example.com\"], \"strictAllowlist\": true } },\n> \"editorMode\": \"vim\",\n> \"env\": { \"MY_VAR\": \"1\" }\n> }\n> }\n> ```\n>\n> The same override key also carries `sandbox`, the sibling top-level settings subtree governing the sandbox commands run in (`sandbox.network.*`, `sandbox.filesystem.*`, `sandbox.credentials`, `sandbox.allowAppleEvents`, ...). It has no canonical permission category either — it constrains _how_ a permitted command runs rather than which commands are permitted — so it is a verbatim passthrough on the same terms, merged into the top level of `settings.json` and round-tripped back on import — except for the three paths naming an executable, covered by the Trust caveat below, which are dropped in both directions. The merge is recursive, unlike the flat `permissions` fields above: `sandbox` subtrees carry restriction lists (`network.deniedDomains`, `filesystem.denyRead`), so setting one flag under `network` must not drop the denials beside it. A sibling key at any depth survives; a list you author replaces the existing list rather than being appended to. **Scope caveat:** Claude Code honors a subset of `sandbox.*` only from user settings, managed settings and the `--settings` flag — `filesystem.disabled`, `network.strictAllowlist`, `network.tlsTerminate`, `credentials.allowPlaintextInject`, `credentials.awsPairs`, `credentials.sigv4` and `allowAppleEvents` — and ignores them in a repository's `.claude/settings.json` / `.claude/settings.local.json`. Rulesync therefore skips those keys when generating project scope (warning once per key) and emits them only under `--global`, so it never _writes_ a project-scope sandbox policy that does nothing. The same restriction applies **per entry** inside `credentials.files` and `credentials.envVars`: an entry with `\"mode\": \"mask\"` is ignored in a repository's settings file, so Rulesync drops just those entries at project scope (warning once per list) while keeping the `deny` entries in the same list, which every scope does honor. Values already in the file are left untouched — both ones you hand-wrote and ones an earlier Rulesync version generated — because the two cannot be told apart and clobbering your file would be worse; so an inert key committed before this behavior existed stays there until you remove it by hand. Import stays scope-agnostic. See the [sandboxing docs](https://code.claude.com/docs/en/sandboxing).\n>\n> **Any other key is a top-level `settings.json` key.** Everything in the `claudecode` block other than `permission`, `permissions`, `sandbox` and `hooks` is written straight to the top level of `.claude/settings.json` and round-trips back on import, so a setting Claude Code adds needs no Rulesync release to become authorable — `editorMode`, `emojiCompletionEnabled`, `workflowSizeGuideline`, `keybindingFlavor`, `env`, `model`, `alwaysThinkingEnabled` and anything after them all work the same way. The merge is recursive, like `sandbox`, so setting one key under `env` keeps the variables already in the file. `hooks` is excluded because the [hooks feature](#rulesynchooksjsonc) owns it, `permission`/`permissions`/`sandbox` because they have their own handling above, and `$schema` because it is an editor pointer rather than a Claude Code setting. **Scope caveat:** the [settings reference](https://code.claude.com/docs/en/settings-reference) documents a scope per key, and Rulesync skips a key the file it is writing cannot honor (warning once per key): keys scoped `User or managed` / `User, local, or managed` (`spellcheck`, `autoMode`, `vimInsertModeRemaps`, `pluginConfigs`, `sshConfigs`, `syncClaudeAiSkills`, ...) are skipped at project scope and emitted only under `--global`, while keys scoped `Managed` (`allowManagedHooksOnly`, `requiredMinimumVersion`, `strictKnownMarketplaces`, ...) or `Global config` (`diffTool`, `autoConnectIde`, `copyOnSelect`, ...) are skipped in both scopes, because neither file Rulesync writes is the file that reads them — set those by hand in the managed settings file or `~/.claude.json`. Keys the settings reference marks as removed (`teammateDefaultModel` since v2.1.234, `permissionExplainerEnabled` since v2.1.257, `taskOutputMaxChars` since v2.1.277) are skipped in both scopes too, with a warning that names the removing version, since current Claude Code versions ignore them wherever they are set. The alternate spellings `additionalMarketplaces` and `allowedMarketplaces` are resolved to their canonical keys (`extraKnownMarketplaces` and `strictKnownMarketplaces`) before that check, so an alias is treated exactly as the key it spells. The scope caveat also reaches inside `env`, per variable, following the settings reference's [list of variables Claude Code ignores in `env`](https://code.claude.com/docs/en/settings-reference#variables-claude-code-ignores-in-env): the variables a checked-out repository should not control — `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `HOME`, `TMPDIR`, `TMP`, `TEMP` and `XDG_*`; the Windows `SystemRoot`, `ComSpec`, `ProgramData`, `LOCALAPPDATA`, `PATHEXT`, `PSModulePath` and `ProgramFiles*` / `ProgramW6432` (matched without regard to case); `OTEL_LOG_RAW_API_BODIES`, `ENABLE_BETA_TRACING_DETAILED` and `BETA_TRACING_ENDPOINT`; the telemetry switches `CLAUDE_CODE_ENABLE_TELEMETRY`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` and `ENABLE_ENHANCED_TELEMETRY_BETA`, the exporter selectors `OTEL_{LOGS,METRICS,TRACES}_EXPORTER`, the content variables `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_ASSISTANT_RESPONSES`, `OTEL_LOG_TOOL_CONTENT` and `OTEL_LOG_TOOL_DETAILS`, every `OTEL_EXPORTER_OTLP_*` variable ending in `_ENDPOINT`, `_HEADERS`, `_PROTOCOL`, `_CERTIFICATE`, `_CLIENT_KEY` or `_INSECURE`, and `OTEL_EXPORTER_PROMETHEUS_HOST` / `_PORT` (ignored there since Claude Code v2.1.282); and `CLAUDE_CODE_PROCESS_WRAPPER`, `CLAUDE_CODE_SYNC_SKILLS`, `CLAUDE_CODE_SYNC_PLUGINS`, `CLAUDE_CODE_PLUGIN_CACHE_DIR` and `CLAUDE_CODE_PLUGIN_SEED_DIR` — are skipped at project scope and emitted only under `--global`. The values that turn telemetry off are the exception and are still written at project scope: `none` for the three exporter selectors, and `0`, `false`, `no` or `off` for `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_CONTENT` and `OTEL_LOG_TOOL_DETAILS` (compared without regard to case). Claude Code versions before v2.1.251 still honored the directory and session-content variables other than `HOME` and `XDG_CONFIG_HOME` from project settings (and versions before v2.1.282 the telemetry group), so on an older Claude Code a project value skipped here would have applied — set it in user settings instead. `CLAUDE_CODE_REMOTE`, `CLAUDE_CODE_ACCOUNT_UUID`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_PROJECT_DIR_NAME`, `CLAUDE_CODE_RESTRICTED` and the `CLAUDE_CODE_DISABLE_*_RM_*` switches are ignored in every settings file (the messaging socket since Claude Code v2.1.224, the messaging token since v2.1.228 and `CLAUDE_CODE_PROJECT_DIR_NAME` since v2.1.234), so they are skipped in both scopes — set them in the environment Claude Code starts from. Each warning names the skipped variables but never their values. The reference introduces its directory and Windows groups with \"such as\", so a variable it does not name is still written. **Trust caveat:** a permissions file is shareable — `rulesync fetch` copies `.rulesync/permissions.jsonc` out of another repository — so a file whose name promises restrictions must not be able to hand Claude Code a command to run. Rulesync therefore refuses the keys whose value _is_ an executed command, in both scopes and in both directions: `apiKeyHelper`, `awsAuthRefresh`, `awsCredentialExport`, `fileSuggestion`, `gcpAuthRefresh`, `otelHeadersHelper`, `policyHelper`, `processWrapper`, `statusLine` and `subagentStatusLine` are never written (warning once per key) and are silently dropped on import — author commands in [`.rulesync/hooks.jsonc`](#rulesynchooksjsonc), where a reviewer expects them, or set these by hand in `settings.json`. The same line applies inside `sandbox`, which has its own merge branch: `sandbox.ripgrep`, `sandbox.bwrapPath` and `sandbox.socatPath` each name an executable Claude Code runs, so they are refused in both scopes and dropped on import too. The scope caveat reaches inside `sandbox` as well: `sandbox.filesystem.allowManagedReadPathsOnly` and `sandbox.network.allowManagedDomainsOnly` are scoped `Managed`, so they are skipped in both scopes for the same reason the `Managed` top-level keys are — set them in the managed settings file by hand. Keys that widen what Claude Code trusts rather than running something themselves are still written, but every one of them is named in a single summary warning per file — one line listing each setting and what it affects, rather than a run of near-identical lines: `env`, `disableAllHooks`, `disableSkillShellExecution` set to anything but `true` (which re-opens inline shell execution a user setting had turned off), `enableAllProjectMcpServers`, `enabledMcpjsonServers` and `allowedMcpServers`, `autoMode`, `skipAutoPermissionPrompt` and `skipDangerousModePermissionPrompt`, `enabledPlugins` and `extraKnownMarketplaces`, `agent` and `outputStyle` (which replace the prompt and tools every session starts with), `httpHookAllowedEnvVars` and `allowedHttpHookUrls` (which relax what an existing HTTP hook may send and where), `claudeMdExcludes` (which drops the CLAUDE.md files its patterns match), `crossSessionInbound` set to anything but `hold` or `refuse` (which lets messages from your other sessions reach Claude), `modelOverrides` (which decides the inference profile a call is routed to), `skipWebFetchPreflight` set to anything but `false` (which turns off the WebFetch domain safety check), `remoteControlAtStartup` set to anything but `false`, `prUrlTemplate` (which rewrites the PR links Claude Code renders), `companyAnnouncements`, `permissions.additionalDirectories`, a `permissions.defaultMode` of `bypassPermissions`, `acceptEdits` or `auto`, and the `sandbox` paths that loosen the sandbox rather than naming something to run — `enabled`, `autoAllowBashIfSandboxed`, `allowUnsandboxedCommands`, `excludedCommands`, `allowAppleEvents`, `enableWeakerNestedSandbox`, `enableWeakerNetworkIsolation`, `ignoreViolations`, `filesystem.allowRead`, `filesystem.allowWrite`, `network.allowedDomains`, `network.allowMachLookup`, `network.allowUnixSockets`, `network.allowAllUnixSockets`, `network.allowLocalBinding`, `network.httpProxyPort` and `network.socksProxyPort`. The `allow*` lists are in that set because Claude Code merges a list across settings scopes rather than replacing it, so a project file can only ever add to them; their `deny*` counterparts, which restrict, are not. These warnings fire only on the value that actually loosens the policy, so authoring the restrictive one (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays quiet. Each condition names the value that stays quiet rather than the one that warns, so a value of the wrong type — a `skipWebFetchPreflight` of `1` rather than `true` — is reported rather than passed over. `allowUnsandboxedCommands` and `autoAllowBashIfSandboxed` both default to `true`, and an explicit `true` is reported even so: a project `.claude/settings.json` outranks the user file, so writing it there re-opens what a user's `false` closed. `remoteControlAtStartup` is the one key whose _scope_ depends on its value — Claude Code honors a `false` from a project file but ignores a `true`, so a `true` is skipped at project scope and emitted only under `--global`. They are also emitted after the scope filter, so a path the scope drops is reported as skipped rather than as written. Every one of these warnings exists for the same reason: a value that arrived with a fetched override should be visible rather than silent. (`env` is warned about rather than refused because it has too many ordinary uses to drop, even though a value such as `NODE_OPTIONS` or `PATH` does run code.) As with `sandbox`, the filter only applies to what Rulesync writes: a value already in the file is left untouched, and removing a key from the override block does not delete the value an earlier generate wrote — clear it in `settings.json` by hand.\n\nFor OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. OpenCode's `webfetch`, `websearch`, `todowrite`, `question`, and `doom_loop` keys accept only a single action string, so Rulesync emits their canonical `{ \"*\": \"allow\" }` form as `\"allow\"`. If one of these categories contains pattern-specific rules, Rulesync collapses them to the most restrictive action (`deny` > `ask` > `allow`) and logs a warning because OpenCode cannot represent those patterns; a map without `*` includes an implicit `ask` fallback so a narrow allowlist never becomes blanket `allow`, while an empty map becomes `deny` instead of falling through to OpenCode's default allow behavior.\n\nCanonical categories are written under the key OpenCode actually consults: `agent` becomes `task` (and `task` imports back as `agent`), and `write` is folded into `edit`, because OpenCode's write tool asks for the `edit` permission and has no `write` key. When both `write` and `edit` are set, their patterns are merged into one `edit` map, emitted where the canonical `edit` category sits (OpenCode applies the last matching rule across keys as well, so the merged map keeps `edit`'s place relative to keys such as `*`); identical maps are kept as-is, otherwise each pattern keeps the stricter action and the patterns are reordered `allow`, then `ask`, then `deny` (OpenCode's last matching pattern wins, so this order keeps the merged map at least as strict as either input) and a warning is logged. The merge fails closed: an `allow` carve-out under a broader `ask`/`deny` pattern (e.g. `\"*\": \"deny\", \"src/**\": \"allow\"`) stops taking effect once the two maps differ, so give both categories the same map, or set `opencode.permission.edit` (which replaces the merged value) when you need an exact pattern order. Keys under `opencode.permission` are OpenCode's native keys and are emitted as written. An imported `edit` stays `edit`. OpenCode has no notebook tool, so `notebookedit` rules are skipped with a warning.\n\n> **OpenCode-only override (`opencode` key):** OpenCode exposes permission categories that other tools do not understand (e.g. `external_directory`). Placing these in the shared `permission` block would push meaningless entries into Claude Code, Codex, etc. To scope them to OpenCode, add a tool-scoped `opencode` override key alongside the shared block — mirroring the tool-scoped override keys used by [hooks](#hooks) (`opencode.hooks`) and rules frontmatter. Categories under `opencode.permission` are merged on top of the shared block **per category** (the override wins) and are emitted **only** into `opencode.json` / `opencode.jsonc`; every other tool ignores them. Values may use a bare action string (`\"deny\"`) or, for OpenCode keys that support fine-grained matching, a pattern map (`{ \"*\": \"ask\" }`).\n>\n> ```jsonc\n> {\n> \"permission\": {\n> \"bash\": { \"git *\": \"allow\", \"*\": \"ask\" },\n> },\n> // Emitted only into opencode.json's `permission`; never leaks to other tools.\n> \"opencode\": {\n> \"permission\": {\n> \"external_directory\": \"deny\",\n> },\n> },\n> }\n> ```\n>\n> On **import**, any OpenCode category that is not a shared canonical rulesync category (`bash`, `read`, `edit`, `write`, `webfetch`, `websearch`, `grep`, `glob`, `notebookedit`, `agent`, the all-tools key `*`, or an `mcp__*` tool name) is routed into the `opencode` override rather than the shared block, so a subsequent `rulesync generate` does not leak it into other tools.\n>\n> You may also override a **shared** category for OpenCode specifically (e.g. put `webfetch` under `opencode.permission` to give OpenCode a different value than the shared block sends to other tools). On generate this works as expected, but note the override is not round-trip stable for shared categories: re-importing the generated `opencode.json` classifies a shared category back into the shared block, so prefer expressing OpenCode-only categories here and keeping cross-tool categories in the shared block.\n\nFor Hermes Agent, permissions are written into the shared `~/.hermes/config.yaml` (global only). Canonical rules map onto the structures Hermes's runtime actually enforces:\n\n- `bash` `allow` patterns → `command_allowlist`. The allowlist is a list of shell-command patterns, so `allow` rules in other categories are not written there: a `read` or `edit` allow names a path, and an all-tools `*` allow need not name a command either (it is skipped with a warning; write it under `bash` to auto-approve it as a command).\n- `bash` `deny` patterns → `approvals.deny` — Hermes's hard denylist, evaluated **before** `--yolo` / `approvals.mode: off`.\n- `webfetch` `deny` patterns → `security.website_blocklist.domains`.\n- The all-tools `*` category's `deny` and `ask` rules are read too — a rule written there covers shell commands as well — but they are not written into `approvals.deny`, since a pattern under `*` need not be a command. Like every `ask` rule (Hermes has no ask tier), they restrict by **withholding** the `bash` `allow` rules they cover: the canonical order is `deny > ask > allow` whatever the width of the patterns, so `{\"*\": {\"npm *\": \"deny\"}, \"bash\": {\"npm publish\": \"allow\"}}` writes no `npm publish` into the allowlist, and `{\"*\": {\"*\": \"ask\"}}` withholds every `allow`. Each withheld rule is reported with a warning.\n- Every `ask` rule, and `deny` rules in categories other than `bash`/`*`/`webfetch`, have no native per-pattern Hermes primitive; they are skipped with a warning and survive only for round-trip (Rulesync also stores the full canonical config under a private `permissions.rulesync` key so `.rulesync/permissions.jsonc` reconstructs losslessly).\n\n> **Hermes-only override (`hermes` key):** Hermes exposes approval/security controls with no canonical permission category — e.g. `approvals` (`mode`, `cron_mode`, `mcp_reload_confirm`, ...), `security` (`allow_private_urls`, ...), `skills.write_approval`, `memory.write_approval`. Add a tool-scoped `hermes` override key alongside the shared block to author them; its contents are **deep-merged** into `config.yaml` (so an `approvals.mode` here coexists with the `approvals.deny` derived from canonical deny rules) and are emitted **only** for Hermes. The block is a verbatim passthrough, so any current or future Hermes config key can be set without Rulesync modeling each one. Note that the deep merge replaces **arrays** wholesale, so setting `hermes.approvals.deny` or `hermes.security.website_blocklist.domains` overrides (does not append to) the list derived from the shared `permission` block — use it only when you intend to replace the canonical-derived deny list for Hermes. The top-level `permissions` key is reserved by Rulesync for the round-trip blob, so a `permissions` key inside the `hermes` override is ignored.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"rm -rf *\": \"deny\" } },\n> \"hermes\": { \"approvals\": { \"mode\": \"smart\" }, \"security\": { \"allow_private_urls\": false } }\n> }\n> ```\n\nFor Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml` under `[permissions.rulesync]` and sets `default_permissions = \"rulesync\"` (project/global depending on mode). It also generates `.codex/rules/rulesync.rules` from `permission.bash` entries using `prefix_rule(...)`. Current Rulesync-to-Codex mapping supports `bash`, `read`, `edit`/`write`, and `webfetch` categories:\n\n- `bash`: generates one `prefix_rule(...)` per command pattern in `.codex/rules/rulesync.rules` (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`). Codex matches each pattern word as a literal token and the rule as a command prefix, so glob characters are not wildcards there. A single trailing standalone `*` is dropped, because a prefix rule already covers whatever follows: `sudo *` becomes `pattern = [\"sudo\"]`, which also matches a bare `sudo` (as the Letta Code and Tabnine `<prefix> *` translations do). More than one trailing wildcard word (`docker * *`) requires extra arguments a prefix rule cannot express, so it is skipped like the cases below. The legacy `git:*` spelling is read as `git *`. Any other wildcard — inside a word (`npm install*`), in the middle (`git * --no-verify`), `?` or a `[...]` class — has no prefix-rule equivalent, and cutting the pattern before it would widen the rule (a deny on `npm install*` would block every `npm` command), so the pattern is skipped with a warning and Codex does **not** enforce it, even a deny. A pattern of only `*` (or the aliases `* *` and `*:*`) is skipped the same way, since a prefix rule cannot match every command; use `codexcli.approval_policy` for that. The rules file is generate-only: `rulesync import` reads `.codex/config.toml` and does not bring `permission.bash` back from it.\n- `read`: `allow` → `read`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`\n- `edit` / `write`: `allow` → `write`, while `ask`/`deny` → `read` in `permissions.<profile>.filesystem` when the path is exact or ends in `/**`; Codex has no path-level write-approval state, so both non-allow write actions preserve read access without write access and emit a warning. An explicit `read: deny`/`ask` on the **same pattern** takes precedence and emits `deny`. A write-side-only rule (no `read` rule for the same pattern) also emits `deny` when the most specific `read` rule covering the path is a `deny`/`ask` — an exact path `X` or `X/**` covering `X` or anything below it when both are the same kind of path (both absolute, drive, or `~` paths, or both workspace-relative — so a relative `C:` never covers the drive path `C:\\secret\\k`; leading `./` segments are ignored, and for Windows drive paths a backslash counts as a separator and the drive letter is compared case-insensitively, so `c:\\secret` covers `C:\\secret\\k`), `**`, `.`, `./**`, or `:workspace_roots` covering any workspace-relative pattern, or `/**` and `:root` covering every path — because Codex gives the more specific path priority and a `read` or `write` entry would re-open reads the broader rule denies (e.g. `read: { \"/home/me/.ssh/**\": \"deny\" }` + `write: { \"/home/me/.ssh/id_rsa\": \"deny\" }` emits `deny` for both). This applies to a write-side `allow` too: `read: { \"~/.ssh/**\": \"deny\" }` + `edit: { \"~/.ssh/known_hosts\": \"allow\" }` emits `\"~/.ssh/known_hosts\" = \"deny\"` with a warning, since \"writable but not readable\" is not expressible in Codex (add `read: allow` for the same pattern to keep it writable). Only the most specific covering `read` rule counts, and equally specific rules resolve to the restrictive one: `read: { \"/x/**\": \"deny\", \"/x/y\": \"allow\" }` + `edit: { \"/x/y/z\": \"deny\" }` emits `\"/x/y/z\" = \"read\"`, while a more specific `read: allow` that Codex never receives (a non-trailing glob or a Windows device path, skipped as described below) does not count. For absolute, drive, and `~/...` paths, coverage resolves `.`/`..` segments lexically, as Codex does, for comparison only (emitted keys keep their authored form), so `read: { \"/home/me/.ssh/**\": \"deny\" }` covers `/home/me/public/../.ssh/id_rsa`. As an extra safeguard, when the path or the `read: allow` pattern contains such a segment the narrower allow never wins (e.g. `read: { \"/home/me/**\": \"deny\", \"/home/me/public\": \"allow\" }` + `edit: { \"/home/me/public/../.ssh/id_rsa\": \"deny\" }` emits `deny`). Other broader read denials are not matched, so Rulesync warns when it maps a write-side-only `deny` to `read`: a non-trailing glob such as `**/*.pem`, special paths other than `:root` and `:workspace_roots` (e.g. `:tmpdir`), and `~/...` versus the equivalent absolute path (`~/.aws/**` does not cover `/home/me/.aws/credentials`, because Codex expands `~` on the machine it runs on, which Rulesync cannot know); no warning is emitted for an explicit `read: allow` + write-side `deny`, which asks for exactly Codex's `read` level. For other globs, Codex supports only `deny`: Rulesync skips grant-derived `read`/`write` entries with a warning so the baseline applies, and emits `deny` for a write-side `ask`/`deny` restriction rather than generating an invalid profile. Windows device paths (`\\\\?\\...`, `\\\\.\\...`) are not supported for grants: Codex rejects them as workspace-root paths, and outside Windows their `?` is a glob character. When Rulesync runs outside Windows, such a path is not absolute and would be nested under `:workspace_roots`, where Codex rejects it under the Windows path convention and treats it as a meaningless literal name elsewhere, so Rulesync drops every entry for it — including a `deny` — with a warning, and **the path is not protected by the generated profile**; express such a restriction with a plain drive path instead. On Windows, where the path is absolute and emitted at the top level, a grant-derived `read`/`write` entry is skipped with a warning and a write-side `ask`/`deny` restriction emits `deny`. Use a plain drive path such as `C:\\...` instead.\n- A Codex `\"write\"` entry is imported as both `read: allow` and `edit: allow`, since Codex's `write` level includes read access; this keeps a writable path under a broader read deny (e.g. `\":root\" = \"deny\"` + `\":tmpdir\" = \"write\"`) writable on the next generate. Import is one-way for the write-side restriction: a Codex `\"read\"` entry is imported as `read: allow` only. Both `read: allow` + write-side `ask`/`deny` and a write-side-only `edit`/`write` `ask`/`deny` (e.g. `edit: { \"/x/**\": \"deny\" }`, which generates `\"/x/**\" = \"read\"`) therefore lose their write restriction on `rulesync import`, and a later generate hands the path back to the baseline's write access. Rulesync does not re-materialize `\"read\"` as an extra `edit: deny`, because that would push a restriction to every other tool that the Codex profile alone cannot prove was authored; re-add such rules to the canonical permissions after importing.\n- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules); `network.enabled = true` is emitted only when at least one `allow` rule is present. Deny-only domain sets are emitted without `enabled`, which Codex treats as restricted (its default) while the deny entries still round-trip back into Rulesync rules. Codex rejects the global wildcard `*` in denied domains at config load time, so `webfetch: { \"*\": \"deny\" }` is skipped with a warning (unlisted domains are denied by Codex's allowlist-first policy anyway); `webfetch: { \"*\": \"allow\" }` is emitted as a regular `\"*\" = \"allow\"` domain entry, which Codex accepts for denylist-only setups ([openai/codex#15549](https://github.com/openai/codex/pull/15549)). On import, `deny` entries are always taken, while `allow` entries are imported only when `enabled = true` is explicit — Codex treats a missing `enabled` as restricted, so importing an allow entry from a disabled profile would activate a grant Codex never had. A Codex profile with `network.enabled = true` but no `domains` is imported as `webfetch: { \"*\": \"allow\" }`, which reflects Codex's default semantics where `enabled = true` grants sandbox-wide network access (under Codex's experimental `network_proxy` feature, `enabled = true` without an allowlist blocks requests instead, and the regenerated `\"*\" = \"allow\"` entry is the closest equivalent).\n\nRelative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.\":workspace_roots\"` instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, `~/...`, or named roots. Leading `./` segments are stripped from these table keys before the `read`/`edit`/`write` rules are merged, because Codex rejects a `:workspace_roots` subpath whose first segment is `.` (only the exact `.` key, the workspace root itself, is special-cased): `./` and `./**` become `.` (not the bare glob `**`, which Codex accepts only with `deny`), and `./src/**` becomes `src/**`. Rules that end up on the same key are merged as if they had been written with it (e.g. `read: { \"src/**\": \"allow\" }` + `edit: { \"./src/**\": \"allow\" }` emits `\"src/**\" = \"write\"`), and within one category the more restrictive action wins (e.g. `./src` and `src`). A workspace-relative pattern Codex would reject — one with a `..` segment (e.g. `src/../secret`, `../out/**`) or an empty leading segment (e.g. `.//src`, which is never turned into the absolute `/src`) — would make Codex refuse the whole config, so Rulesync drops it with a warning; like a Windows device path, **the dropped rule, even a `deny`, does not protect the path**. Absolute, drive, `~/...`, and special-path keys are never rewritten. Codex supports `read`/`write` only for exact paths or trailing `/**` subtree rules; Rulesync skips other grant-derived globs with a warning so the baseline applies, while write-side restrictions fall back to `deny`. Rulesync also sets `glob_scan_max_depth = 8` when generated workspace-root rules contain unbounded `**` patterns. A direct `\":workspace_roots\"` rule (e.g. `read: { \":workspace_roots\": \"deny\" }`) is kept as the table's `\".\" = <access>` entry — the key Codex uses for the workspace root itself — when workspace-relative rules turn `:workspace_roots` into a table, so the workspace does not fall back to the baseline's access (a warning is still logged; if an explicit `.` or `./` rule also exists, the more restrictive access wins). On import, a `\".\"` table entry is imported as the portable `.` pattern rather than the Codex-only `:workspace_roots`, so a canonical `read: { \".\": \"deny\" }` round-trips unchanged.\n\nThe `:workspace_roots` table also receives a default `.git` carve-out: `\".git/**\" = \"write\"`. Codex's `:workspace` baseline keeps `.git` read-only inside workspace roots, which denies basic git workflows (commit/stage operations write to `.git/index`, `.git/objects`, refs, and logs; everyday commands such as `git remote add`, `git push -u`, and local-scope `git config` write to `.git/config`). The write rule reopens the whole subtree, including `.git/config` — an earlier `\".git/config\" = \"read\"` security guard (a writable `.git/config` lets a sandboxed process set keys like `core.fsmonitor` or `core.hooksPath` that execute code outside the sandbox) was dropped because it blocked those everyday commands while the protection it added was already partial (`.git/hooks/`, and `.git/modules/**` for submodules, remains writable so hook managers such as lefthook and simple-git-hooks keep working; a sandboxed process could still install a hook directly). Users who want stricter isolation can author a more specific rule (e.g. `read: { \".git/config\": \"allow\" }` or `read: { \".git/hooks/**\": \"allow\" }`) in the canonical permissions, which wins over the default (Codex resolves the more specific path with priority). Because `.git/**` is an unbounded `**` pattern, the carve-out also means `glob_scan_max_depth = 8` is effectively always emitted unless it is suppressed.\n\nThe carve-out is skipped in these cases: a user rule for the same pattern always wins per key; the `codexcli.git_write_rules` override set to `false` suppresses it entirely (only an explicit `false` does; the default is `true`); and it is not injected when `codexcli.base_permission_profile` is `\":read-only\"` (it would grant `.git` write access inside a sandbox the user explicitly chose to keep read-only) or when the canonical rules contain a direct `\":workspace_roots\"` pattern (a whole-tree access decision that the defaults must not override). It is also skipped, with a warning, when the most specific other entry of the generated `:workspace_roots` table that covers `.git` is `\"deny\"` or `\"read\"` — for example `\"**\" = \"deny\"` from `read: { \"**\": \"deny\" }`, `\".\" = \"deny\"` from `read: { \".\": \"deny\" }`, or `\".\" = \"read\"` from `edit: { \".\": \"deny\" }` — because Codex resolves the more specific `.git/**` entry with priority, so the carve-out would otherwise reopen `.git` under that restriction. A narrower rule only keeps the carve-out when it grants `.git` write access: `read: { \"**\": \"deny\", \".git\": \"allow\" }` emits `\".git\" = \"read\"` and still skips it, while adding `edit: { \".git\": \"allow\" }` (`\".git\" = \"write\"`) keeps it. `:root` and `/**` denies do not suppress it, because they are not table entries and the `:workspace` baseline re-grants the workspace above them; to keep `.git` writable anyway, author `read` and `edit` allows for `.git/**` (both are needed, since a write-side allow alone under a broader read deny generates `\"deny\"`), or set `codexcli.git_write_rules: false` to silence the warning when the restriction is intended. Coverage ignores non-trailing globs such as `**/.git/**`. Like `:minimal`, the default-valued carve-out is not imported into the Rulesync model on `rulesync import` — it is re-added on every generate — while customized `.git` values import normally. Because the skip decision reads only the emitted table, import applies the same test: a `\".git/**\" = \"write\"` entry in a table that would suppress the default is imported as a user-authored rule, so the result is stable across generate → import → generate. One limitation: the `git_write_rules` flag itself cannot be recovered from `config.toml`, so it does not round-trip through `rulesync import`; if you opted out with `false`, re-add the flag to the canonical permissions config after importing (and if you want the same `.git` rules while opted out, author them as canonical `read`/`write` rules rather than hand-writing them in `config.toml` — though note that import cannot tell a user-authored `\".git/**\" = \"write\"` from the default carve-out, so unless the rest of the table restricts `.git` as described above, that exact pattern/value pair is still skipped on import and must be re-authored in the canonical config afterwards). Migration note: configs generated before the `\".git/config\" = \"read\"` default was removed still carry that entry, and `rulesync import` now treats it as a user-authored rule — it lands in the canonical config as `read: { \".git/config\": \"allow\" }` and, because Codex gives the more specific path priority, keeps `.git/config` read-only on every regenerate. If you want the current writable default instead, delete that rule from the canonical permissions after importing. Likewise, configs generated before the carve-out became conditional carry `\".git/**\" = \"write\"` even in tables that restrict `.git` (e.g. next to `\"**\" = \"deny\"`); `rulesync import` treats that entry as user-authored and adds `read`/`edit` allows for `.git/**` to the canonical config, which other tools then receive too. Delete those allows after importing unless you want `.git` writable.\n\nThe generated `[permissions.rulesync]` profile always extends one of Codex's built-in permission profiles via `extends`. The baseline is chosen with the `codexcli.base_permission_profile` override key (`\":read-only\"` | `\":workspace\"` | `\":danger-full-access\"`) and defaults to `\":workspace\"` when unspecified. Codex's built-in `:workspace` baseline grants read access to the whole filesystem and write access to the entire workspace root plus `/tmp` and `$TMPDIR` (with carve-outs protecting `.git`, `.codex`, and `.agents`), while `:read-only` keeps command execution read-only; the generated `filesystem` entries then grant or deny access on top of the chosen baseline. Codex's third built-in profile, `:danger-full-access`, is rejected by `extends` at Codex config load time — so selecting it works differently: Rulesync emits `default_permissions = \":danger-full-access\"` directly and skips the managed `[permissions.rulesync]` profile entirely (with the sandbox removed there is nothing for filesystem/network rules to refine; canonical `read`/`edit`/`write`/`webfetch` rules are ignored for Codex CLI with a warning, and any stale managed profile from a previous generate is pruned while sibling hand-written profiles are preserved). On import, a profile's `extends` value round-trips back into `codexcli.base_permission_profile` when it names one of the two extendable built-ins, and a top-level `default_permissions = \":danger-full-access\"` round-trips the same way; a custom parent profile is skipped and replaced by the managed baseline on regeneration (with a warning).\n\nRulesync emits `\":minimal\" = \"read\"` in the generated filesystem table by default. This enables `include_platform_defaults()` ([FileSystemSpecialPath::Minimal](https://github.com/openai/codex/pull/13434)), which provides the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows. `:minimal` is the only special path treated as a fixed baseline: it is always present in the generated table and is never imported into Rulesync's own permission model, regardless of its value. A canonical rule for `:minimal` still overrides the emitted value on generate (e.g. a `write: { \":minimal\": \"allow\" }` rule emits `\":minimal\" = \"write\"` — see the [FAQ](../faq.md#codex-cli-denies-ssh-agent-access-temp-dir-writes-or-reading-its-own-config-with-a-generated-permissions-profile) for when that is needed), but because import always skips `:minimal`, such a customization does not round-trip: after `rulesync import`, re-author the rule or the next generate falls back to `\"read\"`. The other special paths `:root`, `:tmpdir`, and `:slash_tmp` are user-managed access rules that are imported into the Rulesync model and re-emitted from it like any ordinary filesystem entry (`:root = \"deny\"` becomes a read/edit deny, `:tmpdir = \"write\"` becomes an edit allow, and so on). Because they round-trip through `.rulesync/permissions.jsonc` rather than relying on an existing `.codex/config.toml`, a restrictive value such as `:root = \"deny\"` survives a fresh-clone `rulesync generate` with no pre-existing Codex config.\n\n`network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration — as it does any other network key it does not model (e.g. `dangerously_allow_all_unix_sockets` or Codex's proxy keys), since network settings are user territory by design. `network.enabled` is only half-managed: Rulesync sets `enabled = true` itself when the canonical model contains an allow domain, but when a regeneration computes no `enabled` value, a user-authored `enabled` is preserved (with a warning) instead of being deleted — see the [FAQ](../faq.md#codex-cli-denies-ssh-agent-access-temp-dir-writes-or-reading-its-own-config-with-a-generated-permissions-profile) for the recommended user-managed entries. The preservation applies only when the existing profile carries no allow domain: an existing `enabled` next to allow domains is Rulesync's own managed output, so removing every webfetch allow rule from the canonical model removes `enabled` too (falling back to Codex's restricted default) instead of leaving an unscoped `enabled = true` behind. Note that `filesystem`, `network.domains`, and `extends` are always managed by Rulesync (`filesystem`/`network.domains` derived from `edit`/`write`/`webfetch` rules, `extends` from `codexcli.base_permission_profile`), so hand-authored values in those fields will be replaced on regeneration.\n\n> **Codex CLI-only override (`codexcli` key):** Codex CLI's permission surface is richer than the canonical allow/ask/deny model — its approval workflow, permission-profile baseline, and per-app tool gating have no canonical category. Add a tool-scoped `codexcli` override to author them: except for `base_permission_profile`, its fields are written verbatim as **top-level `.codex/config.toml` keys** (the override wins per key; existing sibling keys the user set directly are preserved, and table values are shallow-merged) while the shared `permission` block keeps driving the managed `[permissions.rulesync]` profile and `default_permissions`. Supported keys: `base_permission_profile` (`:read-only` | `:workspace` | `:danger-full-access`, default `:workspace` — not a top-level key; it becomes the managed profile's `extends` baseline, or with `:danger-full-access` the directly-selected `default_permissions` value, see above), `approval_policy` (`on-request` | `never`, or a `{ granular = { … } }` table kept verbatim; defaults to `on-request` when neither the override nor the existing config sets it — `on-failure` is a deprecated alias Codex still reads as `on-request` and is written with a warning; `untrusted` was [retired in Codex 0.149.0](https://github.com/openai/codex/pull/39630) and makes Codex refuse to start, so an authored `untrusted` is skipped with a warning (the key then falls back to the existing value or the default), an `untrusted` left behind in `config.toml` by an earlier Rulesync is replaced with the default on generate rather than preserved, and an existing `untrusted` is not imported — each with a pointer to the replacement — a user-level `[projects.\"<path>\"] trust_level = \"untrusted\"` entry, which is per-machine state Rulesync does not author), `apps` (per-app tool gating — `apps.<id>.tools.<tool>.approval_mode` / `.enabled`, `apps.<id>.default_tools_approval_mode`), `approvals_reviewer` (`user` | `auto_review` (legacy alias `guardian_subagent`), or a table; defaults to `auto_review` when neither the override nor the existing config sets it), `tui` (the `[tui]` table — e.g. `vim_mode_default = true` for the modal Vim composer added in Codex 0.129.0, and the `tui.keymap.*` bindings beside it; the shallow merge applies one level deep, so authoring `keymap` replaces the whole existing `[tui.keymap]` table rather than merging into it), and `git_write_rules` (boolean, default `true` — like `base_permission_profile` it is not a top-level key: it controls whether the managed profile's `:workspace_roots` table emits the default `.git` carve-out described above; only an explicit `false` suppresses it). **Deprecated:** `sandbox_mode` (`read-only` | `workspace-write` | `danger-full-access`) with the sibling `sandbox_workspace_write` table (`network_access`, `writable_roots`, …) belong to Codex's classic sandbox system, which permission profiles supersede — Codex prioritizes these legacy keys over permission profiles when both are present, so authoring them disables the generated `[permissions.rulesync]` profile; they are still accepted (with a warning) so existing configs round-trip, but use `base_permission_profile` and the shared `permission` block instead. On import, the top-level keys round-trip back into the `codexcli` override, and the managed profile's `extends` round-trips into `base_permission_profile`. It is a `looseObject`, so future top-level Codex config keys can be authored here (merged verbatim on generate; only the listed keys are re-extracted on import). Example: `{ \"permission\": { … }, \"codexcli\": { \"base_permission_profile\": \":workspace\", \"approval_policy\": \"on-request\", \"approvals_reviewer\": \"auto_review\" } }`. **Out of scope:** `mcp_servers.*` per-MCP gating is **not** authorable here — it is owned by the MCP feature (`codexcli-mcp.ts` writes the `mcp_servers` tables in the same `config.toml`), and `permissions` / `default_permissions` are owned by the canonical model; any such key placed in the override is skipped with a warning. See the [Codex configuration reference](https://developers.openai.com/codex/config-reference) and [permissions docs](https://developers.openai.com/codex/permissions).\n\nFor Kiro, this generates tool permission settings in `.kiro/agents/default.json` (project mode):\n\n- `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands`\n- `read` maps to `toolsSettings.read.allowedPaths` / `toolsSettings.read.deniedPaths`\n- `edit` / `write` map to `toolsSettings.write.allowedPaths` / `toolsSettings.write.deniedPaths`\n- `grep` maps to `toolsSettings.grep.allowedPaths` / `toolsSettings.grep.deniedPaths`\n- `glob` maps to `toolsSettings.glob.allowedPaths` / `toolsSettings.glob.deniedPaths` (both emitted only when a rule is present, so existing configs do not gain empty tables)\n- `webfetch` / `websearch` with pattern `*` map to `allowedTools` entries (`web_fetch` / `web_search`)\n- `ask` rules are skipped with a warning (Kiro config does not support explicit ask entries)\n\n> **Kiro-only override (`kiro` key):** Kiro's agent config exposes per-tool `toolsSettings` knobs with no canonical allow/ask/deny category. Author them through a tool-scoped `kiro` override under `toolsSettings`: the shell auto-trust flags `shell.autoAllowReadonly` / `shell.denyByDefault`, the `aws` built-in tool's `allowedServices` / `deniedServices` (+ `autoAllowReadonly`), and the `web_fetch` domain trust arrays `trusted` / `blocked` (regex host patterns; Kiro documents these for `web_fetch` only — `web_search` has no domain-trust surface). Example: `{ \"permission\": { … }, \"kiro\": { \"toolsSettings\": { \"shell\": { \"autoAllowReadonly\": true }, \"aws\": { \"allowedServices\": [\"s3\"], \"deniedServices\": [\"eks\"] }, \"web_fetch\": { \"trusted\": [\".*github\\\\.com.*\"] } } } }`. The override is **deep-merged per `toolsSettings` key** (the override wins at the leaf) so authoring `shell.autoAllowReadonly` keeps the canonical-generated `shell.allowedCommands`; the shared `permission` block keeps driving `shell.{allowed,denied}Commands`, `read`/`write`/`grep`/`glob` paths, and the `web_fetch`/`web_search` `allowedTools` toggles. Existing non-canonical `shell` flags are preserved across regenerate even without an override. On **import**, these Kiro-specific surfaces are lifted into the `kiro` override so they round-trip. It is a `looseObject` at every level, so future Kiro `toolsSettings` fields pass through verbatim. Kiro MCP `disabledTools` lives in the separate `.kiro/settings/mcp.json` file and is modeled by the MCP feature; MCP `autoApprove` remains outside this permissions translator. See the [Kiro built-in tools](https://kiro.dev/docs/cli/reference/built-in-tools/) and [configuration reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference/) docs.\n\nFor Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (project mode) or `~/.cursor/cli-config.json` (global mode). Cursor CLI only supports `allow` and `deny` decisions, so `ask` rules are skipped with a warning. Tool categories are mapped to PascalCase Cursor tool names (`bash` → `Shell`, `read` → `Read`, `edit`/`write` → `Write`, `webfetch` → `WebFetch`, `mcp__*` → `Mcp`). Existing Cursor-specific entries that Rulesync does not manage (for example, MCP entries with extra fields) are preserved on round-trip. Note Cursor scopes the file asymmetrically — \"Only permissions can be configured at the project level. All other CLI settings must be set globally\" — so in project mode Rulesync contributes only the `permissions` key, and no longer stamps `version` or `editor.vimMode` there (both are written in global mode, where Cursor reads them). Content already in a project `cli.json` is passed through untouched either way, including a `version` an earlier Rulesync version stamped: Rulesync cannot tell a key it wrote from one you wrote, so it does not delete it.\n\n> **Cursor-only override (`cursor` key):** Cursor's `cli.json` carries scalar autonomy settings with no canonical permission category — `approvalMode` (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object (`mode`/`networkAccess`). Add a tool-scoped `cursor` override to author them: its fields are merged into the top level of the config file while the shared `permission` block keeps driving the `permissions.allow`/`permissions.deny` arrays (the override cannot clobber that managed block). These settings are **global-only** upstream, so they are written only when generating with `--global`; in project scope they are skipped with a warning naming each one, rather than written into a `.cursor/cli.json` where Cursor would ignore them and the authored setting would silently never take effect. On import, `approvalMode` and `sandbox` round-trip back into the `cursor` override. It is a `looseObject`, so `sandbox`'s (currently undocumented) value set passes through verbatim and extra `cli.json` keys can be authored here (they are merged verbatim on generate); note that only `approvalMode` and `sandbox` are re-extracted on import.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"cursor\": { \"approvalMode\": \"auto-review\" }\n> }\n> ```\n>\n> The separate Cursor **IDE** `permissions.json` (`mcpAllowlist`, `terminalAllowlist`, `autoRun.*`) is a different file and is not targeted by this translator.\n\nFor GitHub Copilot (`copilot`), this manages the three `chat.tools.*.autoApprove` maps and the `chat.permissions.default` setting in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: `bash` → `chat.tools.terminal.autoApprove` (command patterns), `edit` → `chat.tools.edits.autoApprove` (file globs) and `webfetch` → `chat.tools.urls.autoApprove` (URL patterns). In all three, `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. The canonical `read` category has no VS Code approval surface, and `write` is deliberately **not** folded into the edits map alongside `edit` — doing so would make the two indistinguishable on import — so neither is emitted. VS Code also accepts a `{ \"approveRequest\": …, \"approveResponse\": … }` object per URL pattern; that form has no canonical equivalent, so it is skipped on import, and because Rulesync owns the key outright it is replaced whenever the canonical config carries any `webfetch` rule. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only those keys non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The optional `copilot.defaultPermissionMode` override writes VS Code's `chat.permissions.default` setting — the permission level new chat sessions start with: `default` (Default Permissions), `autoApprove` (Bypass Approvals) or `autopilot` (Autopilot). Without the override the key is left as it is in the file, and on import a recognized value is read back into the override. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals) and the [edit-approval docs](https://code.visualstudio.com/docs/copilot/chat/review-code-edits).\n\nFor Zoo Code (`zoocode`), this manages the two command lists `zoo-code.allowedCommands` and `zoo-code.deniedCommands` in the workspace `.vscode/settings.json` (project mode only). Zoo Code is a VS Code extension and has no policy file in its `.roo/` tree; these two settings are contributed without a `scope`, which in VS Code means they are settable per workspace, and `ClineProvider.mergeCommandLists()` unions the workspace values into the lists the auto-approval decision reads. Only the canonical `bash` category maps, since Zoo Code gates terminal commands and nothing else through these settings: `allow` → `allowedCommands`, `deny` → `deniedCommands`, and an `ask` rule is represented by **omitting** the pattern from both lists so Zoo Code falls through to its own approval prompt. Entries are matched as command **prefixes**, and Zoo Code resolves a command matching both lists by the **longer** prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists imports as `deny`. Patterns are literal prefixes rather than globs: apart from a bare `*` (the one entry treated as a wildcard), a pattern such as `rm -rf *` is compared with `startsWith` and so never matches `rm -rf /`. Rulesync handles that mismatch by which way it fails. A glob- or regex-shaped **deny** fails open — the entry never matches, so the command stays auto-approved — so the literal prefix it pins down is **added alongside** it (`rm -rf *` also emits `rm -rf `, `/^curl /` also emits `curl `), which makes the deny take effect and denies at least everything the original named; the addition is reported at generate time so you can narrow it if it is wider than you meant. Your own pattern stays in the file, so importing the settings back does not quietly narrow the canonical rule and change what other targets generate from it. The added prefix comes back with it, though: importing from these targets leaves the derived entry in the canonical config alongside the pattern you wrote, and every other target then emits it too (`Bash(rm -rf *)` gains a `Bash(rm -rf )` beside it). It denies no more than the pattern it came from, so it is redundant rather than wrong — delete it from `.rulesync/permissions.jsonc` if you would rather not carry it. A deny that pins down no prefix at all is left as-is and reported as inert instead: `*.sh` would yield the empty prefix, which `startsWith` matches every command against, and an alternation such as `npm run (build|test)` names alternatives rather than a prefix — write one entry per alternative. A glob-shaped **allow** fails closed — it approves fewer commands than it looks like it does, and the rest reach the approval prompt — so it is passed through unchanged and only warned about. Only unambiguous matcher syntax counts here: `*`, grouping and alternation, a leading `^`, and **anchored** `/^…/` regex literals. Everything else is a literal prefix and is left strictly alone. That includes absolute command paths — `/bin/sh` and `/etc/init.d/x` have the shape of a regex literal but are commands, which is why the anchor is required — and shell syntax generally, since Zoo Code restores variables, brackets, braces and quoted strings verbatim before matching: `echo $HOME`, `[ -f x ]` and `mv a{,.bak}` really do match, and widening them would auto-deny far more than you wrote. Once the `bash` category is stated, Rulesync owns both keys, and the two empty cases differ because their contributed defaults do. An empty allow list is written as `[]`: Zoo Code reads the _effective_ setting value, and `allowedCommands` is contributed with the default `[\"git log\", \"git diff\", \"git show\"]`, so removing the key would silently re-grant those three auto-approvals. An empty deny list **retracts** its key instead: `deniedCommands` is contributed as `[]`, so nothing resurfaces, and because VS Code resolves array settings by scope precedence rather than by merging, writing `[]` per workspace would erase a deny list you hand-authored in your user-scope `settings.json`. A canonical config that states no `bash` category at all leaves both keys exactly as you wrote them. `.vscode/settings.json` is a general workspace file (JSONC) shared with the `copilot` target's `chat.tools.*.autoApprove` keys, so Rulesync merges only its own keys non-destructively and never deletes the file. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The `zoo-code.*` namespace is Zoo-era (the v3.74.0 rebrand renamed it from `roo-cline.*`), so the `roo` target deliberately does not emit these keys and writes its own lineage's spelling instead — see the Roo Code paragraph below. See the [Zoo Code settings contributions](https://github.com/Zoo-Code-Org/Zoo-Code/blob/main/src/package.json).\n\nFor Roo Code (`roo`), this manages the same pair of command lists under the archived lineage's own spelling, `roo-cline.allowedCommands` and `roo-cline.deniedCommands`, in the workspace `.vscode/settings.json` (project mode only). The extension's package name is `roo-cline`, so that is the namespace its contributed settings live under; both keys are present in Roo Code's final release, v3.54.0, which is why they are in the `roo` target's scope rather than being a post-fork Zoo Code addition. The mapping is identical to Zoo Code's, and shares its implementation: only the canonical `bash` category maps, `allow` → `allowedCommands`, `deny` → `deniedCommands`, an `ask` rule is represented by omitting the pattern from both lists, once the category is stated an empty allow list is written as `[]` so the contributed `allowedCommands` default cannot resurface while an empty deny list retracts its key so a user-scope deny list survives, glob- or regex-shaped denies additionally emit the literal prefix they pin down (and are reported) while glob-shaped allows are passed through with a warning, and a canonical config stating no `bash` category leaves both keys untouched. Roo Code resolves a command matching both lists by the **longer** prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists still imports as `deny`. Because `roo` and `zoocode` write different keys in the same file, enabling both targets leaves all four keys in `.vscode/settings.json` and neither adapter touches the other's pair; the general advice to pick one of the two targets per project still applies, since every other feature they share writes the same files. See the [Roo Code v3.54.0 settings contributions](https://github.com/RooCodeInc/Roo-Code/blob/v3.54.0/src/package.json).\n\nFor IBM Bob (`bob`), permissions are **global-only** and manage the command lists of the `approval` block in `~/.bob/settings/settings.json`, the user settings file Bob IDE and Bob Shell share. Bob documents that block for the user file only; whether a workspace `.bob/settings.json` honors it is undocumented, so `--global` is required. Only the canonical `bash` category maps, onto the `execute_command` entry of `approval.allowedExecutors`: `allow` → `approvedCommands` and `deny` → `deniedCommands`. Bob matches each entry as a **prefix** of the full command line, and `deniedCommands` always takes precedence over `approvedCommands`. `ask` has no list of its own — a command in neither list keeps Bob's approval prompt — so an `ask` rule writes nothing and withholds every allow rule it overlaps, compared at prefix width: `{ \"git \": \"allow\", \"git push *\": \"ask\" }` writes no `git ` entry, because that prefix would auto-approve `git push`. The restricting rules of the all-tools `*` category are folded in the same way they are for Factory Droid. Because Bob compares literal text, a glob- or regex-shaped `deny` such as `rm -rf *` is written together with the literal prefix it pins down (`rm -rf `) so the deny takes effect, a glob-shaped `allow` is left as written (it approves fewer commands than it looks like), and Bob documents no bare `\"*\"` wildcard, so a `\"*\"` entry is reported as one that never matches. Such a deny — `\"*\"`, `*.sh`, or an alternation that pins down no prefix — fails closed: since it can never block anything, every `allow` entry is withheld (and named in a warning) so the commands it was meant to block reach Bob's approval prompt instead of being auto-approved. Once `bash` is stated, rulesync owns both lists: `approvedCommands` is always written (`[]` when nothing is allowed) and an empty deny list removes `deniedCommands`. When `bash` is not stated, the command lists are left untouched. Other canonical categories (`read`, `edit`, `webfetch`, ...) have no per-pattern counterpart — Bob can only switch a whole tool group on — so their rules are skipped with a warning; rulesync does not infer group switches from them. Bob's whole-group switches are authored through the Bob-only `bob` override block instead, and each `approval` key the block names replaces the one in the settings file (`autoApprove` is merged toggle by toggle instead): `bob.approval.allowed_permissions` (the groups Bob auto-approves: `read`, `edit`, `execute`, `mcp`, `skill`, `todo`, `subtask`, `subagent`, `mode`; a group ID outside that list is written as authored with a warning, so a group Bob adds later still works), `bob.approval.permissionOptions` (per-group options such as `{ \"groupId\": \"read\", \"enableOutsideWorkspace\": true }`; Bob documents `enableOutsideWorkspace` for the `read` group only, so any other group gets a warning), and `bob.autoApprove` (the top-level boolean `autoApprove` toggles such as `{ \"skills\": true }`, merged over the existing object; a toggle other than `skills` is written with a warning), for example `{ \"permission\": {}, \"bob\": { \"approval\": { \"allowed_permissions\": [\"read\", \"todo\"] }, \"autoApprove\": { \"skills\": true } } }`. Because the file is Bob's user settings, a switch the override newly turns on applies to every project on the machine, so it is named in a warning — check it if the permissions file arrived with a repository you cloned. Bob's docs show `execute` in `allowed_permissions` next to the command lists but do not say whether the lists apply without it, so check that group yourself. When neither `bash` nor the `bob` override is stated, no settings file is created. Everything rulesync does not author is preserved: a group switch the override leaves out, executor entries for other tools, other fields of the `execute_command` entry, the `hooks` key written by the hooks feature, and every other setting. On import, `approvedCommands` and `deniedCommands` become `bash` `allow` and `deny` rules (`deny` wins for a pattern in both lists), and `allowed_permissions`, `permissionOptions` and `autoApprove` are lifted into the `bob` override block — from then on rulesync manages those keys, so a change made later in Bob's settings UI is overwritten by the next generate unless you import again. A key holding an entry the override cannot carry (a non-string group ID, an option field other than `groupId`/`enableOutsideWorkspace`, a non-boolean toggle) is not lifted, so a generate leaves it as is. The settings file is never deleted. See the [Bob approval settings docs](https://bob.ibm.com/docs/shell/configuration/approval-settings) and the [Bob Shell skills docs](https://bob.ibm.com/docs/shell/features/skills) for `autoApprove.skills`.\n\nFor Codewhale (`codewhale`), global permissions (`--global`) manage the `[[rules]]` records of `~/.codewhale/permissions.toml`, the sibling of the user `config.toml` and the only place Codewhale reads permission rules (the project `.codewhale/config.toml` overlay can only tighten approval and sandbox settings); project permissions manage the repo constitution described at the end of this section. The legacy `~/.deepseek/` location and a `CODEWHALE_HOME` / `CODEWHALE_CONFIG_PATH` override are not followed; a warning is printed when either variable is set. Codewhale consults typed rules only for shell commands and its file tools, so `bash` maps to `exec_shell`, `read` to `read_file`, `write` to `write_file`, `edit` to both `edit_file` and `apply_patch` (a `write` `deny` / `ask` is written for `apply_patch` too, since a patch can create files), `grep` to `grep_files`, `glob` to `file_search`, and `list` to `list_dir`; other categories (`webfetch`, `websearch`, `mcp`, ...) are skipped with a warning, because a rule for any other tool is never evaluated. The `deny` / `ask` rules of the all-tools `*` category are written for shell commands and for every file tool (a glob one Codewhale cannot match as a path fails closed for the file tools, as below); its `allow` rules are skipped. Codewhale expands no wildcards (`*`, `?` or a `[...]` class). A `bash` pattern of `*` or `**` becomes a tool-wide rule, `git *` becomes the command prefix `git` (matched case-insensitively on word boundaries), and a wildcard-free `allow` becomes an exact command (`command_exact = true`), while a wildcard-free `deny` or `ask` becomes a prefix — the way Codewhale's own deny rules match — so it also covers the command with more arguments. A restricting pattern with a wildcard elsewhere is broadened, with a warning, to the literal prefix it pins down (`git push* --force` → `git`); an `allow` of that shape, or a pattern that pins down no prefix, is skipped. A path pattern of `*` or `**` becomes a tool-wide rule and a plain path becomes an exact `path`; any other glob is skipped, since Codewhale compares paths exactly. A skipped `deny` / `ask` fails closed: every `allow` rule it overlaps (any one command or path matching both) for a tool it was meant to restrict is withheld and named in a warning — the preserved `workspace` grants described below included — so `{ \"*\": \"allow\", \"secrets/**\": \"deny\" }` under `read` writes no `read_file` allow and those reads keep Codewhale's approval prompt, while an allowed `src/a.ts` beside it is still written. Commands are compared case-insensitively; paths are compared case-insensitively with empty and `.` segments dropped (as Codewhale normalizes them) and a `**` segment also matching no directory; an allowed directory for `grep_files`, `file_search` or `list_dir` (or the workspace root `.` for any file tool) covers everything under it; and an absolute, home-relative or `..` path is treated as overlapping, since it cannot be compared with a workspace-relative pattern. Within the file Codewhale decides by action before specificity — `deny` beats `ask` beats `allow` whatever the order — so `{ \"git *\": \"ask\", \"git status\": \"allow\" }` still asks for `git status`. A typed `ask` only prompts in an approval posture that can prompt: under Full Access or auto-approval it does not, and with `approval_policy = \"never\"` it blocks; a `deny` always blocks. Rules rulesync cannot express (a `workspace`-scoped record, an exact `deny`, a tool other than those above, ...) are authored under a tool-scoped `codewhale.rules` array of native records, written ahead of the converted rules as authored except that a missing `action` is written out as `ask`, its Codewhale default. rulesync owns the file's rules with one exception: records carrying a `workspace` — the repository-scoped grants Codewhale's approval card remembers — are kept across a regenerate (remove one with Codewhale's `/permissions remove`, and, once imported, from `codewhale.rules` as well), while an existing record Codewhale would reject is dropped with a warning. The plain prefix and path `ask` rules the approval card also appends cannot be told apart from rulesync's output and are replaced, so import first (or remove them with `/permissions remove`) to keep them. A file that fails to parse is an error. On import, records that round-trip through the mapping above become canonical rules (a prefix as `<prefix> *`, an exact `allow` as the literal command, and an `edit_file` rule — or a `write_file` restriction — only when a matching `apply_patch` rule exists), and the rest — including the weaker of two rules for the same pattern — go to `codewhale.rules`. The file is never deleted and is not created when nothing maps. See the [Codewhale configuration docs](https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md) and the [authorization order docs](https://github.com/Hmbown/Codewhale/blob/main/docs/AUTHORIZATION_ORDER.md).\n\nIn project scope, Codewhale permissions become the enforced `protected_invariants` of the repo constitution `.codewhale/constitution.json`: an invariant object with `paths` that Codewhale compiles into a write hold for `write_file`, `edit_file`, `apply_patch` and `fim_edit`. Each `deny` / `ask` pattern of the `write`, `edit` and all-tools `*` categories becomes one invariant `{ \"text\": \"rulesync permissions: writes to <glob> are denied\", \"paths\": [\"<glob>\"], \"action\": \"block\", \"managed_by\": \"rulesync\" }` (`ask` keeps `action: \"ask\"` and the text `... need approval`), with the strongest action per glob. Because the hold covers every write tool, a `write` restriction also holds edits and an `edit` restriction also holds new files. Repo law has no allow shape, so `allow` rules are skipped, as are the other categories (`read`, `bash`, `webfetch`, ...), with a warning; use `--global` for those. Codewhale matches globs (with `globset`, where `*` and `?` also cross `/`, and matching is case-sensitive) against workspace-relative paths with empty and `.` segments dropped, so patterns are normalized the same way (`./src/./a.ts` → `src/a.ts`, and a trailing `/`, also inside a brace group, becomes `/**`), and an absolute, home-relative, drive-letter or `..` pattern is skipped with a warning. Because a glob Codewhale cannot compile makes it hold every write, rulesync writes only `*`, `?` and `**`: a `[...]` class widens to `?`, and `{a,b}` groups are expanded into separate `paths` (so an empty alternative keeps its meaning — `.env{,.local}` holds both `.env` and `.env.local`, where `globset` would drop the empty one); a pattern with a `\\` escape, a control character, a lone surrogate, unbalanced braces or more than 64 expansions is skipped with a warning. A `codewhale.rules` passthrough is only written in global scope. rulesync replaces only the invariants it wrote (marked `managed_by: \"rulesync\"`, a key Codewhale ignores) and keeps every other key (`authority`, `branch_policy`, `verification_policy`, `escalate_when`, ...) and every hand-written invariant; a restriction that a hand-written invariant already holds is not repeated, but only when the hand-written path is spelled exactly as rulesync would write it or as every canonical pattern that maps to it (such as an imported `docs/{a,b}.md`, as long as `globset` reads that spelling the same way) and both are plain printable ASCII: Codewhale only trims a pattern, so a hand-written `./.env` does not hold `.env`. A hand-written entry Codewhale cannot parse (which makes it hold every write for approval) is kept and reported with a warning. Codewhale reads only the nearest constitution between the workspace and its git root, so creating one below an existing one prints a warning. A new file starts with `schema_version: 1`; the file is never deleted, is not created when nothing maps, and an existing file that is not a valid JSON object, or whose `protected_invariants` is neither an array nor `null`, is an error. On import, every enforced invariant (hand-written ones included; advisory strings, objects without usable `paths` and entries Codewhale cannot parse, such as `\"action\": null`, are skipped) becomes a `write` and an `edit` rule per path, `block` as `deny` and `ask` as `ask`. A `block` denies the write in every posture; an `ask` prompts only in the Ask posture and is a hard block in Auto-Review, Full Access and Never, or whenever auto-approval is on. Shell commands that write a protected path are not held by repo law. See the [enforced repo-law invariants docs](https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md#enforced-repo-law-invariants).\n\nFor the GitHub Copilot CLI (`copilotcli`), this manages the two URL lists in the CLI's settings file: `.github/copilot/settings.json` (project mode, repository scope — the file [shipped in CLI v1.0.60](https://github.com/github/copilot-cli/blob/main/changelog.md)) and `~/.copilot/settings.json` (global mode, user scope). Only the canonical `webfetch` category maps: `allow` → `allowedUrls`, `deny` → `deniedUrls`, and an `ask` rule is represented by **omitting** the pattern, since the CLI prompts for any URL that is in neither list. No other category is emitted — the CLI's `permissions.allow`/`ask`/`deny` rule arrays are accepted only in MDM/enterprise managed settings, and interactive tool approvals are machine-written to `permissions-config.json`, so neither is authorable here. **Scope matters:** the repository-scope key table documents `deniedUrls` (union — a repository may add denials, never remove them) but **not** `allowedUrls`, so an allow rule is only enforceable at user scope; at project scope allow rules are dropped with a warning telling you to author them with `--global`, rather than being written to a key the CLI ignores (v1.0.79 additionally warns on startup about unknown top-level keys in the user `settings.json`, so Rulesync emits documented keys only there too). On import, a project-scope `allowedUrls` is likewise ignored so a dead entry does not become an enforced allow rule, and a pattern present in both lists imports as `deny`. `settings.json` also carries unrelated keys (`model`, `effortLevel`, `hooks`, `sandbox.*`, …), so Rulesync merges only the URL keys non-destructively and never deletes the file. See the [CLI config directory reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference).\n\nFor Kilo Code, this generates the `permission` object in `kilo.jsonc` (project mode) or `~/.config/kilo/kilo.jsonc` (global mode). The shape is identical to OpenCode's (Kilo is an OpenCode fork), so categories like `bash`, `read`, `edit`, `write`, `webfetch`, and `mcp` accept either a string catch-all (`\"allow\" | \"ask\" | \"deny\"`) or a `{ <pattern>: <action> }` map. Other top-level keys in `kilo.jsonc` are preserved on round-trip. **The `permission` object is merged per top-level tool key**: for each tool key present in the rulesync output, that key is replaced entirely from rulesync (rulesync owns its managed keys; manual edits inside a managed key will be overwritten on the next generation). Tool keys that exist in the existing `kilo.jsonc` but are NOT in the rulesync output are preserved verbatim so user-added Kilo-only categories survive regeneration. When a regenerate replaces a key whose existing value contained `deny` patterns that disappear from the new rulesync output, an aggregated `logger.warn` enumerates the dropped patterns (matching the project convention used by every other permissions translator). **Exact denies are written last**: Kilo evaluates the `permission` object last-match-wins in key order, and new keys are appended after the existing ones. Every key whose value is a plain `\"deny\"` and whose name contains no `*` or `?` is therefore moved to the end of the object, so a wildcard key written after it — in particular a catch-all `\"*\": \"allow\"` — cannot lift it, including the `{server}_{tool}` denies the MCP feature writes for `disabledTools`. An exact key matches only its own permission name, so this only ever makes that name stricter; every other key keeps its authored order (a `\"*\"` deny written after a category `allow` still wins). An `\"allow\"`/`\"deny\"` (plain, or a `{\"*\": …}` map) naming a tool of a server listed in the same file's `mcp` block is not imported into the `kilo` override: the MCP feature imports it as that server's `enabledTools`/`disabledTools` and owns it. Edits to other top-level keys (e.g. `model`) are preserved. **Malformed `kilo.jsonc` aborts the run**: the `jsonc-parser` library would otherwise silently coerce a syntax error to `{}` and overwrite the corrupted file with an empty `permission`, dropping the user's existing `deny` rules. Rulesync now surfaces parse errors so the run aborts before any destructive write — matching the strict `JSON.parse` behavior used by every other permissions translator.\n\n> **Kilo-only override (`kilo` key):** Kilo's `permission` object carries tool-specific keys with no canonical permission category — OpenCode-inherited ones (`external_directory`, `doom_loop`, `lsp`, `question`, `todowrite`, `skill`, `task`, `list`) and Kilo-unique ones (`agent_manager`, `notebook_read`, `notebook_edit`, `notebook_execute`, `repo_clone`, `repo_overview`). Add a tool-scoped `kilo` override key alongside the shared block (mirroring the `opencode` override) to author these; entries under `kilo.permission` are merged on top of the shared block **per key** (the override wins) and are emitted **only** into `kilo.jsonc`. Each value may be a bare action string or a pattern map. On **import**, any Kilo key that is not a shared canonical category (`bash`, `read`, `edit`, `webfetch`, `websearch`, `grep`, `glob`, the all-tools key `*`, or an `mcp__*` tool name) is routed into the `kilo` override rather than the shared block, so a subsequent `rulesync generate` does not leak it into other tools.\n>\n> **Kilo-only override (`kilo.sandbox`):** the `sandbox` block Kilo runs commands in is a security surface orthogonal to per-tool allow/ask/deny, with no canonical category, so it is authored under the same tool-scoped `kilo` override: `enabled` (boolean), `network` (e.g. `\"deny\"`), `allowed_hosts` (a list of `host` / `host:port` destination exceptions) and `writable_paths`. It is shallow-merged into the top-level `sandbox` key of `kilo.jsonc` — the override's keys win, unrelated sibling keys you set directly are preserved — and the whole block round-trips back into `kilo.sandbox` on import. **Scope matters here.** Kilo honors `allowed_hosts` and `writable_paths` from the _global_ config only, and lets a project config merely tighten (`enabled: true`, `network: \"deny\"`); a project-level network denial even clears the global destination exceptions. Rulesync mirrors that rather than writing config Kilo would ignore: at project scope only `enabled` and `network` are emitted, and any other key is dropped with a warning telling you to author it with `--global`. See the [sandboxing docs](https://kilo.ai/docs/getting-started/settings/sandboxing).\n\n> **Kilo `markdown_source` scope:** Kilo grants `permission.markdown_source` (external Markdown command sources, written as `\"<absolute>/*\": \"allow\"` patterns) only when the last matching rule comes from the _global_ config and says `allow` — \"Project configuration cannot grant this permission\" ([workflows docs](https://kilo.ai/docs/customize/workflows)). A project-level rule that matches last therefore always blocks, whatever its action: `deny` blocks, `ask` blocks just like `deny` (Kilo never prompts for this permission), and a project `allow` would block too, the opposite of what it says. So when `markdown_source` is generated at project scope — whether it comes from the shared `permission` block or from `kilo.permission` — `deny` and `ask` entries are written and `allow` patterns are dropped with a warning telling you to author it with `--global`; if nothing survives, the key is removed from the project `kilo.jsonc`. Global scope writes the rule verbatim.\n\n> **Name-mismatch traps.** Canonical category names do not always match Kilo's key names: Kilo folds **`write` into `edit`** (there is no `write` key), uses **`notebook_edit`** (not the canonical `notebookedit`) and **`task`/`agent_manager`** (not `agent`), and has **no `mcp` key** (MCP is addressed via `mcp__*` tool-name keys). Rulesync passes key names through verbatim, so author Kilo keys using Kilo's own names (e.g. put a `notebook_edit` rule under `kilo.permission`, not the canonical `notebookedit`). Kilo also treats a `null` action as a delete sentinel; Rulesync does not model `null` and only round-trips `allow`/`ask`/`deny`.\n\nFor MiMo Code, this generates the `permission` object in `.mimocode/mimocode.jsonc` (project mode; an existing `.mimocode/mimocode.json` is updated instead) or `~/.config/mimocode/mimocode.jsonc` (global mode). MiMo Code is an OpenCode distribution with the same permission schema, so everything described for OpenCode above applies, with the tool-scoped override under a `mimocode` key (`mimocode.permission`) instead of `opencode`; on import, keys that are not shared canonical categories are routed into that `mimocode` override.\n\nFor AugmentCode CLI, this generates `toolPermissions` entries in `.augment/settings.json` (project mode) or `~/.augment/settings.json` (global mode). The file is read as JSON with Comments, as Auggie [documents](https://docs.augmentcode.com/cli/config), and only the `toolPermissions` key is rewritten, so comments elsewhere in it survive a regenerate. Each entry has `toolName`, an optional `shellInputRegex` (only for shell commands), and `permission.type` ∈ `\"allow\" | \"deny\" | \"ask-user\"`. Tool category mapping: `bash` → `launch-process`, `read` → `view`, `edit` → `str-replace-editor`, `write` → `save-file`, `webfetch` → `web-fetch`, `websearch` → `web-search`. Auggie's [permissions reference](https://docs.augmentcode.com/cli/permissions) now names these tools `terminal` / `read` / `edit` / `write` and lists the legacy names as aliases of them; Rulesync keeps emitting the legacy names, which the shipped CLI (0.36.0 as of this writing) matches exactly, but treats an existing entry authored under a current name as the same managed tool — it imports as the same canonical category (a `terminal` entry's `shellInputRegex` is recovered like a `launch-process` one), a stale `terminal` / `read` / `edit` / `write` `allow` or `ask-user` row is replaced on regenerate instead of surviving as an unmanaged entry, and a `deny` row under either spelling is preserved as written — its spelling is not rewritten — unless a generated entry re-emits the same shape under the same spelling or the legacy name. Action mapping: rulesync `ask` → AugmentCode `ask-user`. For `bash` patterns other than `*`, the glob pattern is converted to a regex and emitted as `shellInputRegex`. The glob → regex conversion maps `*` to `.*`, `?` to `.`, escapes `\\^$.|+(){}[]`, and anchors at both ends; characters outside that set (notably `-`, `/`, `:`, `,`) are emitted verbatim, so Augment will match them literally. Generated entries are sorted **deny first, ask second, allow last**, with more specific patterns (those carrying `shellInputRegex`) before catch-alls — this is required because Augment's `toolPermissions` is evaluated **first-match-wins**. Existing `toolPermissions` entries whose `toolName` is NOT in the rulesync-managed set are preserved on round-trip, with one exception: a legacy `toolName: \"*\"` basic entry — an inert artifact of older rulesync versions, since AugmentCode only matches exact tool names — is always dropped. Existing **`deny` entries for ANY managed `toolName`** (`launch-process`, `view`, `str-replace-editor`, `save-file`, `web-fetch`, `web-search`, or the current aliases `terminal` / `read` / `edit` / `write`) are also preserved (fail-closed) so a user-added deny rule on any managed tool cannot be silently downgraded by regeneration. Existing managed-tool `allow` / `ask-user` entries are still replaced (rulesync owns the permissive surface for managed namespaces). **Non-bash categories do not have a documented per-input matcher in AugmentCode**, so Rulesync emits at most one catch-all entry per tool: if the rulesync category contains any `deny` rule, Rulesync emits a single `deny` entry for the entire tool (fail-closed) and warns; otherwise only `*`-pattern allow/ask rules are emitted and any non-`*` allow/ask patterns are dropped with a warning. **AugmentCode has no wildcard `toolName`**, so the canonical all-tools `*` category is never emitted as an entry of its own: any `deny`/`ask` rule it carries is folded into `launch-process` — via the same glob shadowing logic bash rules use elsewhere — even when the source states no separate `bash` category, and warns once. The same restriction is also applied as a blanket `deny`/`ask` fallback to any of `view` / `str-replace-editor` / `save-file` / `web-fetch` / `web-search` that would otherwise emit **no entry of its own**; a category that states only non-`*` patterns still counts as emitting nothing, since those patterns are dropped, so it still falls back rather than silently defaulting to AugmentCode's own permissive behavior. An all-tools `allow` never forces an entry, and a tool whose own rules do emit an entry is always left untouched. Importing AugmentCode entries back into rulesync recovers `bash` patterns from `shellInputRegex` but the other categories always import as the catch-all `*` pattern. **The import direction also applies fail-closed precedence** when multiple existing entries collapse to the same `(canonical, \"*\")` key (e.g. `[{view: deny}, {view: allow}]`): the most restrictive action wins regardless of iteration order (precedence: `deny` > `ask` > `allow`), so a user-added deny in the source file is never silently dropped by import order. The `launch-process` (bash) path is unchanged because each entry has its own `shellInputRegex`-derived pattern with no `\"*\"` collapse. On **import** (project scope), Rulesync also reads the layered overrides file `<workspace>/.augment/settings.local.json` — a gitignored, machine-specific file that Auggie merges on top of `settings.json` — and combines it over the base settings before converting to the canonical model, following Auggie's documented layering (simple values take the local override, `mcpServers`/`plugins` replace wholesale, and other objects/lists — including `toolPermissions`, which Auggie concatenates local-first under first-match — are combined across tiers), so personal permission overrides are picked up without dropping a committed base `deny`. This overlay is **import-only and project-only**: Rulesync never writes `settings.local.json` (it stays a user-owned, gitignored file), and AugmentCode documents no global `~/.augment/settings.local.json`, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Every other top-level key is preserved verbatim through the generate round-trip; the plugin-consumption keys `recommendedMarketplaces` and `enabledPlugins` are rewritten only when the `augmentcode` override below states them.\n\n> **AugmentCode-only override (`augmentcode` key):** AugmentCode's `toolPermissions[]` supports \"custom policy\" entries the canonical allow/ask/deny model cannot express — `permission.type` of `webhook-policy` / `script-policy` (delegating the decision to a `webhookUrl` / `script`) and an `eventType` of `tool-response` (a post-execution check rather than the default pre-execution `tool-call`). Author these through a tool-scoped `augmentcode` override with a `toolPermissions` array of verbatim entries: `{ \"permission\": { … }, \"augmentcode\": { \"toolPermissions\": [ { \"toolName\": \"github-api\", \"permission\": { \"type\": \"webhook-policy\", \"webhookUrl\": \"https://api.example.com/validate\" } }, { \"toolName\": \"view\", \"eventType\": \"tool-response\", \"permission\": { \"type\": \"allow\" } } ] } }`. Authored entries are **prepended** — ahead of the canonical-generated basic rules — so a webhook/script gate or tool-response check is never shadowed by a regenerated allow/deny/ask entry under first-match-wins. When the override authors `toolPermissions` it becomes the source of truth for the special entries (the existing file's specials are no longer separately preserved, avoiding a double-emit); without an override, any special entries already present in `settings.json` are preserved verbatim as before. On **import**, special entries are lifted verbatim into the `augmentcode` override (rather than being skipped with a warning) so they round-trip and become user-authorable; basic entries continue to drive the shared `permission` block. The entry objects stay a loose passthrough so `shellInputRegex`, `webhookUrl`, `script`, and future non-policy fields survive untouched, while the documented bounded fields are validated as enums: `permission.type` (`allow` | `deny` | `ask-user` | `webhook-policy` | `script-policy`) and `eventType` (`tool-call` | `tool-response`). Both project and global scope are supported.\n>\n> The same override also authors Auggie's [plugin-consumption settings](https://docs.augmentcode.com/cli/plugins), so a repository can tell its contributors which plugin marketplaces and plugins to use: `{ \"augmentcode\": { \"recommendedMarketplaces\": [\"acme/team-plugins\"], \"enabledPlugins\": { \"code-guard@acme-plugins\": true } } }`. `recommendedMarketplaces` is a list of `\"owner/repo\"` GitHub marketplaces that Auggie offers to install when someone opens the workspace; Auggie honors it only in the project's `.augment/settings.json` and ignores it in user or local settings, so it replaces the project list and is skipped with a warning under `--global`. `enabledPlugins` maps plugin ids (`\"plugin-name@marketplace-name\"`) to `true`/`false` and is written at either scope. Each key is written only when the override states it — otherwise a value already in the file is left untouched — and a stated key replaces the value in the file, so a plugin id dropped from the override is no longer enabled by the project settings (Auggie deep-merges `enabledPlugins` across tiers, so the user and local tiers still apply). Because the stated value owns the whole key, an entry added to `settings.json` after the last import — for example by `auggie plugin install --project` — is removed on the next generate, with a warning naming it; add them to the override, or run `rulesync import --targets augmentcode --features permissions` after installing plugins with Auggie, to keep them. Removing a key from the override altogether does not delete the value an earlier generate wrote; clear it in `settings.json` by hand. Writing a non-empty `recommendedMarketplaces` or an enabled plugin that differs from the value already in the file is logged as a warning, since both make Auggie load code the repository does not ship (a plugin can carry its own hooks and MCP servers). On **import**, both keys are lifted into the `augmentcode` override when they have the documented shape, so a plugin enabled with `auggie plugin install --project` survives the round-trip. Both are read from the committed `settings.json` only: a `recommendedMarketplaces` in `settings.local.json` is ignored, as Auggie ignores it, and an `enabledPlugins` there is not imported, so a plugin enabled for one machine is never promoted to the shared settings. A global import skips `recommendedMarketplaces`, which Auggie ignores in `~/.augment/settings.json`. `dismissedMarketplaces` (managed by Auggie in `settings.local.json`) and `autoUpdateMarketplaces` (a personal user setting) are not authored.\n\nFor Factory Droid, this generates `commandAllowlist` / `commandDenylist` arrays in `.factory/settings.json` (project mode) or `~/.factory/settings.json` (global mode). Factory Droid only gates **shell commands** through these two lists, so the rulesync `bash` category is what is translated: `allow` patterns become `commandAllowlist` entries (run without confirmation) and `deny` patterns become `commandDenylist` entries (always require confirmation; the denylist wins when a command is in both). Factory Droid has **no separate `ask` list** — any command not in the allowlist already prompts — so rulesync `ask` rules write nothing, though an `ask` does withhold every `allow` that names any of the same commands (an `ask` on `npm *` withholds an allowed `npm publish`, an `ask` on `npm publish` withholds an allowed `npm *`, and an `ask` on `* --force` withholds an allowed `git *` even though neither pattern covers the other's spelling — the two overlap on every `git … --force` command), because canonically the stricter rule wins whatever its width, so auto-approving a command the file also asks about would answer the prompt the author wanted. The all-tools `*` category is read as well, but only for its **restricting** rules: a `deny` written there covers shell commands too, so it lands in `commandDenylist` — skipping it would auto-approve the very command the file blocks. A `bash` deny keeps the `allow` beside it, since the denylist outranks the allowlist and a `bash` pattern names a command by construction: `{\"bash\": {\"git *\": \"allow\", \"git push *\": \"deny\"}}` blocks the push and auto-approves the rest. A deny written under `*` is entered in the denylist too — for the case where it _is_ a command — but it **also** withholds the `allow` rules it covers, because a pattern there need not name a command at all: `{\"*\": {\"secrets/**\": \"deny\"}}` beside an allowed `*` would otherwise auto-approve every command while the denylist entry matches none of them. A `*` deny that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported: nothing observed says it names a command, so the denylist entry may block nothing at all — write it under `bash` too if it is a command pattern. A config with no `allow` rules at all, or one that spells the same pattern under `bash` as well, is not reported, since neither says anything about whether the pattern names a command. A `bash` `ask` that withheld no `allow` is **not** reported: Droid prompts for whatever its allowlist does not cover, so an `ask` on a command no `allow` overlaps is already honored exactly as written. An `ask` under `*` writes nothing and withholds the same way a `bash` one does, but one that withheld nothing is reported on the same terms as the `*` deny above — a pattern there need not name a command, and withholding is the only way an `ask` can restrict here, so nothing observed says the rule does anything at all. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction, at the cost of auto-approving less than the config asked for — and says so in a warning. This read is one-way: import writes patterns back into the `bash` category only, never into `*`. Its `allow` rules are deliberately never carried over, since a pattern under `*` need not be a command at all (`secrets/**` there denies a path) — the skip is named in a warning rather than made silently; both directions therefore fail closed. Categories other than `bash` and `*` cannot be represented in the command allow/deny model and are skipped, with a `logger.warn` when a skipped category carries a `deny` or an `ask` rule (to surface the gap — a foreign `ask` restricts as surely as a foreign `deny`). rulesync owns the `commandAllowlist` / `commandDenylist` keys (they are replaced from the rulesync output), while every other key in `settings.json` (e.g. `hooks`) is preserved verbatim on round-trip — except the Factory-specific security keys covered by the `factorydroid` override below, which are lifted into that override on import. Importing reads the two lists back into the `bash` category, with `.factory/settings.local.json` overlaid on top of `settings.json` first — Droid layers the two, so importing without the overlay would read permissions it does not actually enforce. Rulesync never _writes_ `settings.local.json`; generation only ever writes `settings.json`. Note the consequence for a full round-trip: a value that came from the local file is imported into `.rulesync/permissions.jsonc` like any other, so the next `generate` writes it into the shared `settings.json`. If a personal override should stay personal, drop it from `.rulesync/permissions.jsonc` after importing. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Rulesync gitignores `.factory/settings.local.json` itself, matching Factory's guidance to keep it out of version control.\n\n> **Factory Droid-only override (`factorydroid` key):** Factory Droid has security controls that do not fit the per-command `allow`/`ask`/`deny` model — the hard-block `commandBlocklist` tier (commands that can **never** run, not even under full autonomy — distinct from an approvable `deny`), plus `networkPolicy` (`allowedIps`), `sandbox` (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`, autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`, `subagentAutonomyLevel`, `interactionMode`, and the per-tool `mcpAutonomyOverrides`), the plugin-bootstrap keys `extraKnownMarketplaces` / `enabledPlugins` (Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), the `hooksDisabled` kill-switch, `disabledSkills` (an array of skill names to disable without deleting their files), and the organization controls `modelPolicy` and `missionPolicy`. Add a tool-scoped `factorydroid` override to author them: its keys are merged into `settings.json` (the override wins) while the shared `permission` block keeps driving `commandAllowlist`/`commandDenylist`. On **import**, these keys are lifted into the `factorydroid` override — so `commandBlocklist` now round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvable `deny`.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"factorydroid\": { \"commandBlocklist\": [\"curl *\"], \"sandbox\": { \"enabled\": true } }\n> }\n> ```\n\nFor Cline CLI, this generates `.cline/command-permissions.json` (project mode only). Cline reads this file via the `CLINE_COMMAND_PERMISSIONS` environment variable; you can wire it up with `export CLINE_COMMAND_PERMISSIONS=$(cat .cline/command-permissions.json)`. The schema is `{ \"allow\": [...], \"deny\": [...], \"allowRedirects\": false }`. Cline only supports shell commands and only `allow`/`deny`. Categories other than `bash` and the all-tools `*` are dropped, and `bash` `ask` rules are **translated to `deny`** (fail-closed safety, since Cline lacks `ask` semantics). The all-tools `*` category contributes its **restricting** rules — a `deny` written there covers shell commands too, so skipping it would auto-approve a command the file blocks — while its `allow` rules are never carried over, since a pattern under `*` need not be a command at all (the skip is named in the translation notice rather than made silently); both directions therefore fail closed. A `bash` `deny` is written to `deny` and keeps the `allow` beside it — [Cline documents that deny rules always take precedence](https://docs.cline.bot/cli/cli-reference), and a `bash` pattern names a command by construction — which is how a narrow `deny` carves an exception out of a wider `allow` (`git *` allowed, `git push` denied). A `deny` under `*` is written as well, but it **also** withholds the `allow` rules it covers, since a pattern there need not name a command: `{\"*\": {\"secrets/**\": \"deny\"}}` beside an allowed `*` would otherwise auto-approve every command while the `deny` entry matches none. A `*` deny that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported the same way, since nothing observed says it names a command Cline can block. An `ask` under `*` is **not** translated: a pattern written there need not name a command, and denying it outright would turn the ordinary catch-all `{\"*\": {\"*\": \"ask\"}}` into a block on every command — one the additive `deny` merge below would then keep forever. It **withholds** the `allow` rules it covers instead (whatever the two patterns' widths, since canonically the stricter rule wins regardless of how wide it is), which leaves those commands prompting, exactly as an `ask` asks. A `*` ask that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported: withholding is the only way it can restrict here, so nothing observed says the pattern names a command — write it under `bash` if it is one. One that withheld no `allow` at all is not reported, since the `allow` array below is a gate: a command no `allow` covers already prompts, which is what the ask asked for. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction — and says so in the translation notice. Note that Cline's `allow` array is a gate rather than a list of exceptions — once it is present, only the commands matching it run without approval — so a config whose every `allow` is withheld asks about every command. The `*` read is one-way: import writes patterns back into the `bash` category only, never into `*`. All of those translation notices are surfaced via a single aggregated `logger.warn` per generation (matching the project convention used by every other permissions translator) so the translation stays visible without tripping CI gates that treat error lines as failures. **The `allow` array is wholesale-replaced by rulesync** — user-added entries inside `allow` are not preserved on regenerate. **The `deny` array is additive** — user-added denies in the existing file are preserved on every generation alongside the rulesync-derived denies (fail-closed standard). The `allowRedirects` field (a single global boolean gating shell redirection operators `>`/`>>`/`<`) can be authored from rulesync via a tool-scoped **`cline` override** — add `\"cline\": { \"allowRedirects\": true }` alongside the shared `permission` block. Precedence: the `cline` override wins, otherwise the existing file value is preserved, otherwise it defaults to `false`. On import, a `true` value round-trips back into the `cline` override (the default `false` emits no override). Cline does not have a stable per-user file location for command permissions, so global mode is not supported. If a pattern still ends up in **both** `allow` and `deny` — which the additive merge can produce when the existing file carries a hand-written `deny` matching a generated `allow` — Rulesync emits a warning, since deny wins and the `allow` entry therefore has no effect.\n\nFor Zed, permissions are global-only and generate the `agent.tool_permissions` object in `~/.config/zed/settings.json` (`%APPDATA%\\Zed\\settings.json` on Windows). Zed reads a project `.zed/settings.json` as [`ProjectSettingsContent`](https://github.com/zed-industries/zed/blob/main/crates/settings_content/src/project.rs), which has no `agent` field, so no `agent.*` key written there takes effect; project-mode generate and import therefore skip Zed permissions, and an `agent` block an earlier Rulesync version wrote into `.zed/settings.json` is inert and left for you to remove by hand. Each canonical category becomes a key under `agent.tool_permissions.tools.<tool>` (tool-name mapping: `bash` → `terminal`, `edit` → `edit_file`, `write` → `write_file`, `webfetch` → `fetch`, `websearch` → `search_web`; unknown categories pass through unchanged). Per-tool MCP categories are translated: canonical `mcp__<server>__<tool>` becomes Zed's `mcp:<server>:<tool>`, and imports back into the canonical spelling, so a category authored once reaches Zed and the other targets alike (a key already written in Zed's spelling is emitted unchanged but still normalizes to the canonical form on import). Only the first separator is split, so a tool whose own name contains `__` survives the round-trip. Inside an MCP category only the catch-all `*` rule is emitted, as the tool's `default`; pattern-scoped rules are dropped with a warning, because Zed dispatches every MCP tool with a single empty input (\"MCP tools are gated only by tool id (no per-input pattern matching)\"), so a pattern would be matched against `\"\"` rather than against anything meaningful. A category that omits or wildcards either half — `mcp__<server>`, `mcp__<server>__*`, `mcp__*__<tool>` — is dropped with a warning too, since Zed looks the tool up by exact key on the full triple with no glob or prefix matching. Any canonical-spelled `mcp__<server>__<tool>` entry an earlier Rulesync version left in `settings.json` is swept on the next generate, whether or not the current config still names that category: it is not a Zed tool name, so it can only be Rulesync's own output, and leaving it would resurrect stale rules on the next import. Read-only categories are not written at all: Zed's [gated tool list](https://zed.dev/docs/ai/tool-permissions#supported-tools) does not include `read_file`, `grep`, `find_path` or `list_directory` — they are in Zed's own `EXCLUDED_TOOLS` and never consult the permission settings, so neither a per-tool entry nor the global `default` reaches them. Canonical `read`, `grep` and `glob` (and a category naming one of those Zed tools directly) are therefore **dropped**, with a warning when the category carried a `deny` or `ask` rule, rather than written as entries Zed ignores. Zed's read-denial surface is `private_files`, which the ignore feature writes from `.rulesync/.aiignore`. An inert entry an earlier Rulesync version wrote is left in place rather than deleted — Rulesync cannot tell it from one you wrote, and Zed ignores it either way — and it still imports back as the canonical category, so remove it by hand if you want it gone. The canonical `*` category is the exception: its catch-all `*` rule sets the top-level `agent.tool_permissions.default` — rung 6 of Zed's precedence ladder, and the mechanism Zed documents for MCP tools — rather than an inert `tools[\"*\"]` entry (`*` is not a Zed tool name; a stale `tools[\"*\"]` entry written by an earlier version is cleaned up when the canonical config carries a `*` category, and the `default` imports back as `*: { \"*\": <action> }`). Pattern-scoped rules in the `*` category have no Zed counterpart and are dropped with a warning. Within every other category, the catch-all `*` pattern sets the per-tool `default`, while specific patterns become `always_allow` / `always_deny` / `always_confirm` entries of the form `{ \"pattern\": <regex>, \"case_sensitive\": false }`. Action mapping: rulesync `ask` ⇄ Zed `confirm` (`allow`/`deny` are shared). Because Zed matches with regular expressions, patterns are emitted verbatim — author canonical patterns as regexes when targeting Zed. The settings file is shared with the MCP (`context_servers`) and ignore (`private_files`) features, so writes merge non-destructively: unrelated settings, a user-set `agent.tool_permissions.default` (when the canonical config has no `*` category), and any `tools.<tool>` entries NOT managed by rulesync are preserved on round-trip. The canonical model has no slot for per-pattern case sensitivity, so rulesync always emits `case_sensitive: false`; a hand-authored `case_sensitive: true` on a rulesync-managed tool is overwritten on the next generate.\n\n> **Zed-only override (`zed` key):** three Zed `agent` keys sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scoped `zed` override. `zed.sandbox_permissions` is written into `agent.sandbox_permissions`: Zed's OS-level agent sandbox, which since [Zed 1.14.2](https://zed.dev/releases/stable) (2026-08-05) is **on by default** for the `terminal` and `fetch` tools and by default forbids network access, writing outside the project directories, and writing to `.git`. Most real setups therefore need to relax one of `network_hosts` (exact hostnames or leading `*.` wildcards), `allow_all_hosts`, `write_paths`, `allow_fs_write_all` or `allow_unsandboxed` — none of which the canonical categories can express, since this is process containment rather than tool gating. `zed.profiles` is written into `agent.profiles`, Zed's tool-availability layer: a separate enforcement stage from `tool_permissions`, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keys `name`, `tools`, `enable_all_context_servers`, `context_servers`, `default_model`). `zed.default_profile` is written into `agent.default_profile`, the profile the agent starts in (Zed's default is `write`), so an authored profile can be selected without picking it by hand in the agent panel; Zed itself rewrites this key whenever you switch profiles there. Example: `{ \"permission\": { … }, \"zed\": { \"sandbox_permissions\": { \"network_hosts\": [\"*.github.com\"], \"write_paths\": [\"/tmp\"] }, \"profiles\": { \"review\": { \"name\": \"Review\", \"tools\": { \"terminal\": false } } }, \"default_profile\": \"review\" } }`. The `sandbox_permissions` and `profiles` blocks pass through untouched — Rulesync canonicalizes neither, and validates only the documented `profiles` keys (`sandbox_permissions` is unvalidated, since Zed adds to it release over release) — and each is **replaced wholesale** when the override supplies it, since Zed reads each as a single policy unit; omit the key and whatever is already in `settings.json` is left alone rather than deleted, so removing a block is a manual edit. On **import**, all three are lifted back into the `zed` override so a hand-written sandbox policy, profile set or profile selection round-trips — including approvals you did not write by hand, since Zed saves an always-allow you clicked in the sandbox prompt into `agent.sandbox_permissions` itself. Read the imported block before committing it: an ad-hoc `allow_unsandboxed` picked up from your own machine would otherwise be committed to `.rulesync/permissions.jsonc` and regenerated into the user settings of everyone who runs a global generate from it. The same wholesale replace works the other way too — regenerating from an authored override discards approvals Zed had recorded since. The `zed` block authors these three keys and nothing else: `agent.tool_permissions` belongs to the canonical `permission` block, and any other key — a misspelling, or a blunt instrument such as Zed's `agent.always_allow_tool_actions` — is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Like the sibling `agent.tool_permissions`, these keys are written in global scope only, since Zed reads no `agent` key from a project `.zed/settings.json`. See the [Zed sandboxing](https://zed.dev/docs/ai/sandboxing) and [agent profiles](https://zed.dev/docs/ai/agent-profiles) docs.\n\nFor Qwen Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.qwen/settings.json` (project mode) or `~/.qwen/settings.json` (global mode). The format mirrors Claude Code's: entries are `Bash(<pattern>)`, `Read(<pattern>)`, `Edit(<pattern>)`, `Write(<pattern>)`, `WebFetch(<pattern>)`, `WebSearch(<pattern>)`, `Grep(<pattern>)`, `Glob(<pattern>)`, `Agent(<pattern>)`, etc. Other top-level keys in `settings.json` are preserved on round-trip. Patterns may contain nested parentheses (e.g. `Bash(echo (a))`); Rulesync uses the **last** `)` as the closing delimiter when parsing, so inner parens round-trip. Malformed entries (missing closing paren, trailing characters) emit a warning; for **`deny`** they fall back to the catch-all pattern `*` (fail-closed: broadening a deny is the safer direction), but for **`allow` / `ask`** they are **dropped** rather than broadened — silently turning a narrow user rule into `*` would be a fail-open round-trip. Generation does not create the `.qwen/` directory until `writeAiFiles` runs, so dry-run is side-effect-free.\n\nFor Kimi Code, permissions are global-only and generate `[[permission.rules]]` entries in `~/.kimi-code/config.toml`. Canonical categories map to Kimi tool patterns (`bash` → `Bash`, `read` → `Read`, `write` → `Write`, `edit` → `Edit`, `grep` → `Grep`, `glob` → `Glob`, `websearch` → `WebSearch`, `webfetch` → `FetchURL`, `agent` → `Agent`, and `mcp__…` passes through as the MCP tool name); a `*` canonical pattern emits the bare tool name and a specific pattern emits `Tool(pattern)`. Actions map 1:1 to Kimi's `allow` / `ask` / `deny`, and generated rules use `scope = \"user\"`. Kimi evaluates rules first-match-wins, so Rulesync sorts canonical output fail-closed: all `deny` rules precede `ask`, all `ask` rules precede `allow`, and more-specific patterns precede broader patterns within each action. Kimi does not match MCP tool arguments; an argument-specific MCP `allow`/`ask` is skipped with a warning rather than broadened, while an argument-specific `deny` becomes a whole-tool deny with a warning. The optional `kimi-code.defaultPermissionMode` override writes Kimi's top-level `default_permission_mode` (`manual` / `yolo` / `auto`), while `kimi-code.rules` accepts native rules that canonical categories cannot express and emits them first in their authored order. On import, Rulesync preserves the complete ordered rule list under `kimi-code.rules`, including rules that could otherwise fit the shared permission model, so regeneration cannot change Kimi's first-match behavior. Rule entries that name the tool separately (`tool = \"Bash\"` with `match` or `pattern` for the argument) are folded into `Tool(arg)` the way Kimi reads them (`match` wins over `pattern`; a bare `tool` becomes the tool name), so they survive the round trip instead of being dropped. The `[permission]` `allow` / `deny` / `ask` array form is not read on import; it is left in place on regenerate like any other sibling key. A `kimi-code.tools` override writes Kimi's `[tools] enabled` / `disabled` lists — a separate enforcement layer from `[[permission.rules]]`, since a rule prompts while these remove the tool from every agent in every session. Entries pass through verbatim because the section uses agent-file tool syntax (exact built-in names, `mcp__server__*` globs) rather than the canonical category/pattern shape. Note that Kimi registers `[tools]` in its v2 engine, so today it applies under `kimi web` and experimental `kimi -p` rather than the interactive TUI. Like the MCP defaults, the section merges per key: authoring only `enabled` leaves a hand-written `disabled` list alone, and dropping the override leaves the section as it stands. Values are carried through exactly as written, empty lists included — `enabled = []` is an allowlist admitting _nothing_, the strictest setting there is, while an absent `enabled` means no allowlist at all, so the two are never interchanged. The `[permission]` table merges per key the same way: Rulesync owns only `rules`, so a hand-written sibling such as `dangerous_command_guard = false` (the Kimi Code 0.40.0 switch for the built-in dangerous-command policy) is carried through verbatim on regenerate rather than deleted. That switch can also be authored: the optional `kimi-code.dangerousCommandGuard` boolean override writes `[permission] dangerous_command_guard`, and import lifts a boolean value back into it. Omitting the override leaves an existing value in place. Kimi's `KIMI_CODE_DANGEROUS_COMMAND_GUARD` environment variable still takes precedence over the file. The TOML file is shared with hooks, the MCP timeout defaults and other Kimi settings, so updates merge in place and never delete the file. See the [Kimi Code permission docs](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html).\n\n> **Qwen-only override (`qwencode` key):** Qwen's `settings.json` exposes autonomy/sandbox controls with no canonical permission category — under `tools` (`approvalMode` = `plan`/`default`/`auto-edit`/`auto`/`yolo`, `autoAccept`, `sandbox`, `sandboxImage`, `disabled`, `visible` (the deferred-tool startup visibility list, union-merged by Qwen across scopes), `listDirectory` (the `{ enabled }` toggle for the built-in `list_directory` tool, off by default because `glob` covers directory listing — note that Qwen re-enables the tool anyway when `list_directory` is named in the `tools.core` allowlist, an unrelated sibling key the override preserves rather than owns), `todoWrite` (the `{ enabled }` toggle for the built-in `todo_write` tool and its system-prompt guidance, off by default since Qwen Code v0.23.1; `experimental.todoStopGuard` requires it), `eager` (the allowlist of eager-by-default tool names whose schemas stay in the initial model request; every other non-exempt one is demoted to deferred, still registered and callable but only after `tool_search` loads it — and out of reach for a session where `tool_search` is not registered. Unlike `visible` and `disabled`, Qwen does **not** union this list across scopes, so a project value replaces a user one outright, and an explicit `[]` defers every non-exempt tool while omitting the key means no restriction at all), `workflowsEnabled` (which enables the Workflow tool and the `/workflows` command), `workflowNameOnly` (which restricts that tool to named workflows, so every run the model starts can be matched by a `Workflow(name:...)` permission rule; since Qwen Code v0.24.1), and `executionSandbox` (the Linux tool-execution sandbox policy, `{ filesystem, network, backend? }`; since Qwen Code v0.24.4)), `security` (`folderTrust`, the two guardrails on `type: \"http\"` hooks — `allowedHttpHookUrls`, the allowlist of URL patterns a hook may POST to, where an empty list means allow-all, and `allowPrivateNetworkHooks`, which relaxes the private-IP (SSRF) check — and `allowedInsecureVoiceBaseUrls`, the base URLs a voice provider may be reached at over cleartext HTTP or a private address, which also carries the provider API key in its `Authorization` header), `skills` (the skill kill-switches: `disabled`, the hard-disabled skill names; `defaultDisabled`, the names that start disabled; `enabled`, the explicit opt-ins that override `defaultDisabled` — matched against the registered name, so an extension skill is written `rust:pdf`; and `disabledLevels`, the discovery levels — `project`, `user`, `extension`, `bundled` — to skip entirely. Precedence is `disabled` > `enabled` > `defaultDisabled`, and Qwen Code unions every one of these lists across scopes. This is the only way to turn off a bundled or extension skill that rulesync does not generate), `general` (`outputStyle`, the output style that shapes how responses are written: a built-in such as `Concise`, `default`, or the name of a custom style in `.qwen/output-styles/`; rulesync writes only the setting, not the style files, and leaves the other `general` keys alone), and `permissions.autoMode` (the Auto Mode classifier config: `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell`). Add a tool-scoped `qwencode` override to author them: `qwencode.tools`, `qwencode.security`, `qwencode.skills` and `qwencode.general` are shallow-merged into the matching `settings.json` group at the **top level of that group** (an unrelated sibling key such as `tools.core` is preserved, an override key wins, and a nested object the override supplies such as `security.folderTrust` replaces the existing one wholesale rather than being deep-merged), while `qwencode.autoMode` is emitted as `permissions.autoMode` (replacing the existing `autoMode` wholesale) and the shared `permission` block keeps driving the `permissions.allow`/`ask`/`deny` arrays. On import, the documented autonomy keys (`tools.{approvalMode,autoAccept,sandbox,sandboxImage,disabled,visible,listDirectory,todoWrite,eager,workflowsEnabled,workflowNameOnly,executionSandbox}`, `security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks,allowedInsecureVoiceBaseUrls}`, `skills.{disabled,defaultDisabled,enabled,disabledLevels}`, `general.outputStyle`, and `permissions.autoMode`) round-trip back into the override; other `tools`/`security`/`skills`/`general` keys (such as `skills.directories` or `general.vimMode`) are left in `settings.json` and not extracted. Scope caveats apply to six of those keys, because Qwen Code does not let a workspace decide everything a user can. Four of them — `tools.workflowsEnabled`, `tools.executionSandbox`, `security.allowPrivateNetworkHooks` and `security.allowedInsecureVoiceBaseUrls` — are on Qwen Code's workspace-restricted list and are **stripped** out of workspace settings before merging, honoring them in user/system settings only, so that a cloned repository cannot turn on a subagent-spawning tool, choose the sandbox its own commands run in, grant itself private-network access, or add a cleartext destination that its API key is sent to. Rulesync therefore skips those four with a warning when generating project-scoped `settings.json` (a value already written into the project file by hand is left untouched), and emits them only in global mode — where a granting value is announced, naming the key, the value and what it replaced. \"Granting\" follows Qwen Code's own reading: any truthy value for the booleans — so `1`, the string `\"false\"` and an empty array all count, because Qwen Code tests them for truth rather than for content — and a non-empty list for `allowedInsecureVoiceBaseUrls`, whose list it matches a URL against instead, making the empty one fail-closed — and any `executionSandbox` value, `null` included, since it is a policy rather than a switch. (`workflowsEnabled` also answers to the `QWEN_CODE_ENABLE_WORKFLOWS` / `QWEN_CODE_DISABLE_WORKFLOWS` environment variables, disable winning, so a settings value is not the last word.) The other two are written in both scopes, but not silently. `security.allowedHttpHookUrls` sits on Qwen Code's second, _non-overriding_ list: a workspace value is honored only while no user, system or system-defaults scope sets the key, so a repository may narrow where its own hooks send data but never replace the whitelist a user configured in a higher scope. It does replace a list written by hand in that same project file, though, and an empty list is Qwen Code's allow-all — the emptiest value being the widest one is why **every** global write that changes the file is announced, not just a granting one. `security.folderTrust` is read on both sides of the merge: the _initial_ trust decision — the one that settles whether this workspace's own settings are merged at all — is made from user and system settings alone and defaults to trusted, so a project value cannot vouch for its own repository; once the workspace is trusted, the merged value still drives the folder-trust gate, so `{ \"enabled\": false }` in a project file does switch that gate off. Rulesync emits it with a note saying which half it cannot decide, and announces a global write that changes it. Import lifts all six in either scope, because the file being read carries no scope marker. It flags them when the file it read was the project one, since that is the value regenerating with `--global` would promote into one Qwen Code enforces everywhere; a value imported from `~/.qwen/settings.json` is already there, so it is lifted silently. Review a flagged key before you regenerate globally, especially if the `.qwen/settings.json` came from a repository you cloned. The remaining keys are neither stripped nor non-overriding — Qwen Code honors them wherever they are written, with one tighten-only exception noted below — but a global write is still announced whenever it changes what the file said, because that is the write that settles how far the agent may go unattended on every project on the machine: the autonomy and containment controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), `tools.workflowNameOnly` (on Qwen Code's _tighten-only_ list: a workspace may set it to `true` but a workspace `false` is ignored, so a repository can add the named-workflows restriction yet never lift one a user or system setting imposes — the project-scope note says so), the registry controls (`disabled`, which keeps a tool from being registered at all — Qwen Code unions the disabled lists across scopes, so dropping a name only hands the tool back when no other scope still names it — plus `visible`, `listDirectory`, `todoWrite` and `eager` — which does not remove a tool but demotes every non-exempt eager-by-default one it omits to deferred, and replaces rather than unions the list a higher scope set), the Auto Mode classifier config (`permissions.autoMode`, whose replacement rewrites the instructions the classifier follows, including any deny hint already configured), the skill kill-switches (`skills.*` — Qwen Code unions each list across scopes, but the override replaces the list in the file it writes, so dropping a name hands a skill back unless another scope still names it, and `skills.enabled` opts into a skill another scope starts disabled), the output style (`general.outputStyle` — a custom style adds the instructions in its style file to the system prompt), and any key rulesync does not model. That last case matters because `qwencode.tools`, `qwencode.security`, `qwencode.skills` and `qwencode.general` are open objects, so a key Qwen Code adds works before rulesync knows about it — including potent ones such as `tools.discoveryCommand`, which Qwen Code spawns at startup — and rulesync names it rather than writing it silently. Because those groups are open, the _name_ rulesync reports can be authored too, so a warning quotes the key the same way it quotes the value — serialized, with control characters stripped and long text truncated — and neither can forge a warning line of its own. A project-scoped write of one of these keys is reported as well, naming the key, the value and what that particular key decides — the registry controls are described as registry controls rather than as autonomy ones: Qwen Code honors them in a workspace file, and a plain `rulesync generate` is the likelier path for an override that arrived with a repository you cloned. For the keys rulesync writes, only a write that changes what the file already said is reported, so re-running `generate` says nothing new; the note about a key rulesync refuses to write in project scope is printed whatever the file says, because it is about what was left out.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"*\": \"allow\" } },\n> \"qwencode\": {\n> \"tools\": { \"approvalMode\": \"auto-edit\" },\n> \"security\": { \"folderTrust\": { \"enabled\": true } },\n> \"autoMode\": { \"hints\": { \"allow\": [\"Running tests\"] }, \"classifyAllShell\": true }\n> }\n> }\n> ```\n>\n> **Alias overlap:** Qwen's `Read` is a meta-tool that also covers grep/glob/list, so canonical `grep`/`glob` rules are emitted as their own `Grep(...)`/`Glob(...)` entries but overlap Qwen's `Read` category at runtime; and Qwen folds web search into `web_fetch`, so a canonical `websearch` rule (`WebSearch(...)`) may not correspond to a distinct Qwen tool. `tools.disabled` is a hard whole-tool disable (stronger than `deny`) and is only authorable via the override, not the canonical `deny`.\n\nFor oh-my-pi (`omp`), this generates the `tools.approval` and `bash.patterns` keys of `.omp/config.yml` (project mode) or `~/.omp/agent/config.yml` (global mode, default profile; a named profile or `PI_CODING_AGENT_DIR` is not followed, and the legacy `.omp/settings.json` and a global `config.yaml` are neither read nor written). `tools.approval.<tool>` is a per-tool `allow` / `prompt` / `deny` policy that oh-my-pi honors in every approval mode, so a category's catch-all `*` rule is written there (`ask` becomes `prompt`). `bash`, `read`, `edit`, `write`, `grep` and `glob` keep their names, `websearch` becomes `web_search`, `agent` becomes `task`, and any other category (an oh-my-pi-only tool such as `eval`, best authored under a tool-scoped `omp.permission` block, or an MCP tool, which must use oh-my-pi's final registered name such as `mcp__github_create_issue`; a canonical-style `mcp__<server>__<tool>` name logs a warning) is written verbatim (when `agent` and `task` both appear, the stricter policy wins). `webfetch`, `notebookedit` and the bare `mcp` category have no oh-my-pi tool (or key covering every MCP tool) and are skipped with a warning (oh-my-pi fetches web pages through `read`, which a `webfetch` rule does not restrict). The all-tools `*` category has no key of its own: its `deny` and `ask` rules restrict `bash` commands, and they also raise every other tool's policy (the mapped built-in tools, every key Rulesync writes, and every `tools.approval` key kept from the existing file; an oh-my-pi tool with no key, such as an unset `eval`, still follows `tools.approvalMode`): a catch-all one to its own strictness, a pattern-specific one to at least `prompt`, with a warning. Its `allow` rules are skipped with a warning. oh-my-pi has no per-path matcher, so a non-bash category with pattern-specific rules collapses to one policy (`deny` > `ask` > `allow`, with an implicit `ask` when there is no catch-all) and a warning. `bash` rules become the ordered, first-match-wins `bash.patterns` list of `{ match, approval }` records, ordered `deny`, then `prompt`, then `allow` so the first match agrees with the canonical precedence; a bash catch-all `ask` or `deny` is written there as `match: \"*\"`, while a bash catch-all `allow` is written as `tools.approval.bash: allow`, because by default (without `bash.allowCompoundCommands`) an `allow` pattern never approves a compound command line. oh-my-pi, however, lets a critical command such as `rm -rf /` skip a `prompt` pattern unless `tools.approval.bash` prompts (in the default `yolo` mode it otherwise runs), so when the resolved `bash` rules contain any `ask`, Rulesync writes `tools.approval.bash: prompt` (with a warning unless the bash catch-all is already `ask`) and a bash catch-all `allow` becomes a trailing `match: \"*\"` allow pattern instead; a command that no allow pattern matches, including a compound command line, then prompts in every approval mode. oh-my-pi treats only `*` as a wildcard and collapses whitespace runs before matching, so `?` and `[...]` in a bash `deny` or `ask` pattern widen to `*`, while in an `allow` pattern they are matched literally (both with a warning). The optional `omp.approvalMode` override writes `tools.approvalMode`, the tool tiers oh-my-pi auto-approves before it prompts: `always-ask` (read-only tools), `write` (read-only tools and workspace writes) or `yolo` (every tier, oh-my-pi's default); `--approval-mode`, `--auto-approve` and `--yolo` still override it per run, and `tools.approval` policies apply in every mode. Without the override the key is left as it is in the file, and on import a value oh-my-pi honors (it matches the three names exactly) is read back into the override. The file carries every other oh-my-pi setting, so sibling keys such as `bash.enabled` are kept, `tools.approval` entries for tools Rulesync does not manage (anything other than the mapped built-in tools and the keys it writes, e.g. a hand-written `eval` policy) are kept — so a rule for such a tool that is later removed from `.rulesync/permissions.json` stays in `config.yml` until it is deleted by hand — `bash.patterns` is replaced wholesale (oh-my-pi replaces arrays rather than merging them, so a project `bash.patterns` list also hides the global one in that project), and the file is never deleted. On import, `tools.approval` entries become catch-all rules (`tools.approval.bash` becomes the bash catch-all unless a `*` pattern already sets it, and a `deny` always does; a `*` allow pattern imports as `allow` only behind an `allow` policy or next to `ask` patterns with a `prompt` policy, which is how Rulesync writes it, and as `ask` otherwise, since it never approves a compound command line; a `prompt` policy adds no catch-all next to `ask` patterns) and `bash.patterns` become `bash` rules, with whitespace collapsed and later duplicates of a pattern dropped. See the [oh-my-pi settings docs](https://github.com/can1357/oh-my-pi/blob/main/docs/settings.md) and [approval-mode docs](https://github.com/can1357/oh-my-pi/blob/main/docs/approval-mode.md).\n\nFor Pi Coding Agent, this generates the `defaultTools` array in `.pi/settings.json` (project mode) or `~/.pi/agent/settings.json` (global mode). Pi exposes no allow/ask/deny rule surface, so **no canonical permission category maps onto it** — its one repository-syncable tool gate is `defaultTools`, the list of built-in tools enabled at startup (added in Pi v0.84.2). It is an enable-list rather than an allow/deny rule set, so it is authored through a `pi` override block in `.rulesync/permissions.jsonc` — e.g. `{ \"permission\": {}, \"pi\": { \"defaultTools\": [\"bash\", \"edit\", \"write\"] } }` — and round-trips back into it on import. An empty array is meaningful and emitted as written: upstream reads it as \"start with no built-in tools\" while keeping extension and SDK custom tools. **Scope semantics are the opposite of the union merge most targets use**: a project `defaultTools` array _replaces_ the global array rather than adding to it, so the two scopes are written independently and never combined. CLI flags outrank the setting (`--tools` is a strict allowlist over all tools, `--no-tools` disables everything, `--no-builtin-tools` drops the built-in defaults, `--exclude-tools` filters the result). `settings.json` is a hand-edited file holding many unrelated keys (`theme`, `defaultModel`, `packages`, `sessionDir`, …), so writes go through the shared-config gateway with `defaultTools` as the only owned key — everything else is preserved and the file is never deleted. A config that does not state `defaultTools` leaves the key exactly as you left it. See the [Pi settings reference](https://pi.dev/docs/latest/settings).\n\nFor Warp, this generates the command allow/deny regex lists in Warp's global user `settings.toml` (**global mode only** — Warp has no project-scoped permissions file). Since Warp promoted file-backed execution profiles to Stable (2026-07-28), the surface runtime enforcement actually reads is the `command_allowlist` / `command_denylist` arrays of the `default` record under `[agents.execution_profiles.<id>]`; rulesync merges the lists into that `default` profile **in place** whenever the collection exists, preserving every other profile key and every other profile ID. The legacy `agent_mode_command_execution_allowlist` / `agent_mode_command_execution_denylist` keys under `[agents.profiles]` are still written for un-migrated installs and old clients — but on a migrated install they are inert (Warp consumes them only once during its one-shot migration). When the `[agents.execution_profiles]` collection does not exist yet, rulesync deliberately does **not** create it: on such an un-migrated install the legacy keys are still live, and creating the collection would mark Warp's migration complete early and strand the user's other legacy settings. Note that rulesync manages only the `default` profile — if a different execution profile is active in Warp, the generated lists (including `deny` rules) are not enforced until the user switches back to `default`. The settings file path differs per platform: macOS `~/.warp/settings.toml`, Linux `~/.config/warp-terminal/settings.toml`, Windows `%LOCALAPPDATA%\\warp\\Warp\\config\\settings.toml`. The `bash` category maps (`allow` → allowlist, `deny` → denylist); Warp matches commands with **regular expressions**, so patterns are emitted verbatim — author canonical `bash` patterns as regexes when targeting Warp (mirrors Zed). Warp has no per-command `ask` list, so `ask` rules write nothing — but an `ask` still withholds every `allow` that names any of the same commands (an `ask` on `npm *` withholds an allowed `npm publish`, an `ask` on `npm publish` withholds an allowed `npm *`, and an `ask` on `* --force` withholds an allowed `git *` even though neither pattern covers the other's spelling), since canonically the stricter rule wins whatever its width. A `bash` `ask` that withheld no `allow` is not reported, since Warp prompts for whatever its allowlist does not cover and the rule is therefore honored as written. An `ask` written under `*` is reported in that case, on the same terms as a `*` deny: Warp has no ask list to write it to, so withholding is the only way it restricts, and a pattern there need not name a command — `.*--force` is read as a glob needing a literal leading `.` and overlaps none. The all-tools `*` category is read for its **restricting** rules only, and those never reach the denylist: writing any denylist replaces Warp's built-in default one (see below), and a `*` pattern need not name a command at all, so an inert `secrets/**` entry there would trade real protection for a rule that matches nothing. A `deny` or `ask` under `*` therefore withholds the `allow` rules it covers instead — restricting in the same direction without touching the denylist — and is reported in a warning; author the rule under `bash` as a regex to have it enforced as a command. Its `allow` rules are never carried over either, since a pattern under `*` need not be a command — the skip is named in a warning rather than made silently, and both directions fail closed. Note that this read is one-way: import writes patterns back into the `bash` category only, never into `*`. Because Warp's patterns are regexes while the overlap test compares globs, each `bash` pattern is first rewritten into the widest glob it could stand for: a class (`[rf]`), a group (`(sudo )?`), a character escape (`\\s`), a code-point escape together with its digits (`\\x20`, `\\u0020`, `\\U00000067`, `\\x{263A}`), a Unicode-class escape together with its class (`\\pL`, `\\p{Greek}`, `\\PL`), a top-level `|`, a quantifier together with the atom in front of it (`commits?` covers `git commit`, `ab*c` covers `ac`), and a missing `^`/`$` anchor all become `*`. A construct rulesync cannot read as one sequence makes the **whole** pattern `*` — a class, group or quantifier that never closes, a `{` that spells no repetition (`{a|b}` is an alternation in braces, not a quantifier), or a class holding a nested `[` (Rust's regex crate, which Warp matches with, builds one class out of `[a[b]c]`) — since guessing at the rest could only narrow the result. The rewrite only ever widens, so an inexact reading withholds an allow rather than writing one the config restricts. Note what an unanchored pattern means here: `secret .*` matches any command that merely _contains_ `secret `, so it overlaps an unanchored `git .*` and withholds it — anchor both ends (`^git .*$`, `^secret .*$`) when an `ask` on one command should leave the rest auto-approved. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction — and says so in a warning. A pattern written under the all-tools `*` category is deliberately **not** rewritten: it is canonical, read by every tool, so it is a glob already and is compared as one (`secrets/**` there is a path, not a regex that matches every command mentioning `secrets`). The consequence is worth spelling out: a **regex** written under `*` is not understood as one here, so `.*--force` there is read as a glob needing a literal leading `.` and overlaps no command — author a Warp command rule under `bash`, where it is read as the regex you wrote. Categories other than `bash` and `*` are skipped (with a warning when they carry `deny` or `ask` rules). Writing a `command_denylist` at all **replaces** Warp's built-in default denylist — which covers `rm`, `curl`, `wget`, `eval`, `ssh`, shells, and other risky command patterns — so rulesync warns whenever it emits a non-empty denylist; add canonical `deny` rules equivalent to the built-in patterns you want to keep (see the [Warp CLI permissions docs](https://docs.warp.dev/cli/permissions-and-profiles/)). On import, the `default` execution profile's lists are preferred (falling back to the legacy keys when no collection exists), and a pattern present in both lists resolves to `deny` (Warp's denylist wins). Both blocks are merged into the existing `settings.toml`, preserving other Warp settings, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a settings file holding no command list would say nothing (an existing one is still rewritten). **rulesync owns the command lists** (it is the source of truth): they are replaced from the rulesync config on each `--global` generate, so a manually curated Warp allowlist/denylist not mirrored in `.rulesync/permissions.jsonc` is overwritten — keep command permissions in rulesync (run `rulesync import` first to capture an existing hand-curated list). MCP allow/deny is a separate Warp surface not modeled here. See the [Warp agent profiles & permissions docs](https://docs.warp.dev/agent-platform/capabilities/agent-profiles-permissions/).\n\n> **Warp-only override (`warp` key):** Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy knobs that do not fit the per-command `allow`/`ask`/`deny` model — `agent_mode_coding_permissions` (`always_ask_before_reading` / `always_allow_reading` / `allow_reading_specific_files`), `agent_mode_coding_file_read_allowlist` (an array of paths the agent may read), and `agent_mode_execute_readonly_commands` (a boolean auto-executing read-only commands). Add a tool-scoped `warp` override to author them: its keys are merged into `[agents.profiles]` (the override wins) while the shared `permission` block keeps driving the command lists. On **import**, these keys are lifted from `settings.toml` into the `warp` override, so they round-trip faithfully instead of being dropped. These legacy autonomy keys are part of Warp's one-shot migration, so on a migrated install they are inert; their execution-profile counterparts are authored through the nested `warp.execution_profile` block instead — `read_files` / `apply_code_diffs` / `execute_commands` / `mcp_permissions` (each `agent_decides` / `always_allow` / `always_ask`), `write_to_pty` (`always_allow` / `always_ask` / `ask_on_first_write`), `ask_user_question` (`never` / `ask_except_in_auto_approve` / `always_ask`), `run_agents` (`never_allow` / `always_allow` / `always_ask`), `computer_use` (`never` / `always_ask` / `always_allow`), `directory_allowlist` (paths readable without approval), and `mcp_allowlist` / `mcp_denylist` (MCP server IDs). Its keys are merged into the `default` record of `[agents.execution_profiles.<id>]` under the same guard as the command lists (only when the collection already exists — creating it would complete Warp's migration early; a warning is logged and the block skipped on an un-migrated install), unknown keys pass through verbatim for forward compatibility (export-only: import lifts back exactly the permission keys listed above, while profile-management keys such as `name` or the model overrides never round-trip), and the rulesync-owned `command_allowlist`/`command_denylist` always win. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git .*\": \"allow\" } },\n> \"warp\": {\n> \"agent_mode_coding_permissions\": \"always_allow_reading\",\n> \"agent_mode_execute_readonly_commands\": true,\n> \"execution_profile\": {\n> \"read_files\": \"always_allow\",\n> \"directory_allowlist\": [\"/home/me/projects\"],\n> \"mcp_denylist\": [\"untrusted-server\"]\n> },\n> \"auto_approve_bypasses_command_denylist\": false\n> }\n> }\n> ```\n>\n> **Auto-approve and the denylist:** with auto-approve on, Warp also runs commands that match your own `command_denylist`, because `auto_approve_bypasses_command_denylist` defaults to `true`. Only team denylist rules from the Admin Panel are never bypassed. Set `warp.auto_approve_bypasses_command_denylist` (a boolean) to control it. It is not an execution-profile field, so rulesync writes it to the sibling `[agents.warp_agent.other]` table of the same `settings.toml`, keeping the other keys of that table. It is written on un-migrated installs too, because it is not part of Warp's execution-profile migration. On import it is lifted back into the `warp` override. While the bypass is on (the key is unset or `true`), the denylist-replacement warning also says that auto-approve bypasses the generated denylist. See the [Warp Agent CLI permissions docs](https://docs.warp.dev/agents/cli/permissions-and-profiles/).\n>\n> See the [Warp settings reference](https://docs.warp.dev/terminal/settings/all-settings/).\n\nFor the standalone Warp Agent CLI (target `warpcli`), this generates the command allow/deny regex lists in the CLI's own `settings.toml` (**global mode only**), separate from the Warp app's file: macOS `~/.warp_cli/settings.toml`, Linux `~/.config/warp-terminal/cli/settings.toml`, Windows `%LOCALAPPDATA%\\warp\\Warp\\config\\cli\\settings.toml`. The CLI always runs with the reserved `default` execution profile, so rulesync writes only the `command_allowlist` / `command_denylist` of `[agents.execution_profiles.default]`, converted exactly as for the `warp` target above (including the warning that writing a denylist replaces the built-in one). Unlike the app, the CLI never read the legacy `[agents.profiles]` keys and has no settings migration, so no legacy keys are written and the `default` record is created when absent. The CLI's other execution-profile knobs (`read_files`, `apply_code_diffs`, `run_agents`, …, the same keys as `warp.execution_profile`) are authored through a tool-scoped `warpcli.execution_profile` block, merged into the `default` record before the rulesync-owned command lists, and lifted back into it on import. `warpcli.auto_approve_bypasses_command_denylist` (a boolean) works as described for `warp` above: it is written to `[agents.warp_agent.other]` of the CLI's `settings.toml` and lifted back on import. Set it to `false` to keep the generated denylist in force under auto-approve. Every other key of the file is preserved, the file is never deleted, and it is not created when neither a command rule nor a `warpcli` override key maps (an override alone does create it). See the [Warp Agent CLI permissions docs](https://docs.warp.dev/agents/cli/permissions-and-profiles/).\n\nFor deepagents-cli (dcode), this generates the `[shell].allow_list` array of the user config `~/.deepagents/config.toml` (**global mode only** — dcode reads no project-level config file). dcode auto-approves a shell command when the **executable name** of every segment of its pipeline is in that list and asks about everything else, matching the first token exactly (no globs) after rejecting command substitution, redirects, and process substitution outright. Canonical `bash` rules are therefore reduced to their executable: `git *`, `git:*`, `git commit:*`, and a bare `git` all emit `git` — every spelling but the first two widens the rule, since dcode holds no arguments to narrow it by, so they are written with a warning. The wildcard pattern `*` (or its `*:*` and `* *` spellings) becomes the sentinel `allow_list = [\"all\"]`, which auto-approves every command **and** skips that dangerous-pattern check, so it warns too, and is emitted as the sole entry since dcode rejects the option outright when `all` shares the list. When the same config **restricts** anything — a `deny` or an `ask`, for commands or for another tool — `all` is not written at all: turning the dangerous-pattern check off and approving every command unseen, in the name of a rule meant to restrict, would leave you with less than dcode's own default, so the stricter rule wins and the `*` allow is dropped with a warning. A pattern whose executable still holds a glob (`npm-*`), a shell metacharacter (`git;rm`, `$(id)`), a quote (`\"git\"`) or an escape (`\\git`), or is longer than the 255 characters any file name can hold, is skipped with a warning — dcode compares the name exactly, so a glob matches nothing, and a name holding a metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written, as is one that reduces to a sentinel name (`all`, `recommended` — use `*` for \"every command\"). `ask` needs no output of its own, since a command outside the list already prompts; `deny` has no counterpart at all — dcode asks about an unlisted command rather than blocking it — so a deny rule with no allow entry to hold back is reported as skipped. The all-tools `*` category restricts commands too, so its `deny` and `ask` rules are read alongside the `bash` ones (its `allow` rules never are — a pattern under `*` need not be a command at all — so both directions fail closed, and the skipped allow rules are named in a warning): a `*` deny of `rm *` beside a `bash` allow of `rm *` withholds that allow rather than quietly auto-approving `rm`. Because an entry auto-approves its executable however it is invoked, an `ask` or `deny` on any command that entry would run collides with it — a narrower pattern (`npm publish`) against an allowed `npm *`, and equally a pattern naming no executable at all (`*delete*`, `*--privileged*`) against an allowed `kubectl` or `docker`, since the entry runs those invocations unasked — and dcode has no denylist to settle that collision in — so the colliding `allow` entries are **withheld** from `allow_list` instead: keeping them would auto-approve the very commands the author wanted stopped, while dropping them leaves those executables prompting, which is what an `ask` asks for and the closest dcode comes to a `deny`. Only the colliding entries go; allow rules the restriction says nothing about stay. That covers a narrower rule (`npm publish` or `npm* publish` against `npm *`) and a broader one alike (`*` against every allow, `npm*` against every allowed name it matches), because canonically the stricter rule wins whatever its width — Rulesync collapses colliding rules as deny > ask > allow, and Claude Code applies deny first, then ask, then allow. A quoted or escaped executable (`\"git\" push`, `\\git push`) is read the way dcode reads it — without them — so an odd spelling does not hide the collision. The rule that withheld them is named in a warning, so a deny rule narrower than you meant is visible as the reason a command started prompting. Matching each restriction against what every entry approves is bounded work as well, so a configuration large enough to exhaust that budget treats every allowed executable as colliding and withholds them all — the same fail-closed direction, and the warning says the limit was reached rather than naming a collision nothing compared. On **import**, entries come back as `bash` allow rules named by the executable — an entry dcode could not match in the first place (`git status`, `npm-*`, anything holding a shell metacharacter, a quote or an escape such as `git;rm`, `$(id)` or `\\git`, which dcode splits, rejects or unquotes before it ever compares a name, or a name longer than 255 characters) is skipped with a warning rather than recorded as a permission the tool is not applying, and sentinels are recognized case-insensitively because upstream lowercases them — `all` as `*`, and a list mixing `all` with command names as nothing, since upstream raises on that combination and ignores the option, so those commands are not actually auto-approved. An `allow_list` holding any non-string element, or one that is not a list or a string at all, imports as nothing too, with a warning: dcode requires every element to be a string and drops the whole option otherwise. A `recommended` entry is dropped rather than expanded, with a warning of its own, because the curated list behind it is upstream's to edit — the next generate therefore drops those commands from the allowlist, which fails closed, so re-add by name the ones you want. The `all`-plus-names case warns too, since dcode ignores the whole option there and none of those commands is actually auto-approved. `config.toml` holds every dcode setting, so the `[shell]`, `[startup]`, `[extensions]` and `[mcp]` tables are merged in place and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a file holding no setting would say nothing (an existing one is still rewritten); the merge is a parse and a re-emit, which keeps every unrelated key and table but **does not preserve comments**. A `shell`, `startup`, `extensions` or `mcp` that is not a table at all is left exactly as you have it, with a warning, rather than replaced. See the [deepagents-cli configuration docs](https://docs.langchain.com/oss/deepagents/code/configuration).\n\n> **deepagents-only override (`deepagents` key):** dcode's approval mode is a separate axis from the allowlist and has no canonical per-command slot, so it is authored through a tool-scoped `deepagents` override whose `startup` block is merged into `[startup]` verbatim (unknown keys pass through for forward compatibility): `mode` (`manual` / `auto` / `yolo`, the approval mode a bare launch starts in), `yolo_switcher` (whether YOLO stays in the Shift+Tab mode cycle), and `read_project_dotenv` (whether an untrusted repository's `.env` is loaded into the process environment). On **import**, exactly those three keys are lifted back into the override, and only when the value is one dcode itself would accept (a known `mode`, a real boolean for the switches) — anything else is left behind with a warning, because dcode falls back to its own default for it and writing it would produce a permissions file the next generate could not parse. `startup.recent` is deliberately not lifted at all, since dcode rewrites it as the user cycles modes and committing it would publish one session's state. Generate drops `recent` for the same reason and one more: with no explicit `mode` beside it, that key is what restores auto-approval at launch, so writing it would be a second, quieter way for a repository to change a machine's approval mode. Because this block is written into your **global** config from a `.rulesync/permissions.jsonc` a repository can carry, a value that relaxes what dcode does on its own — `mode` of `auto` or `yolo`, or a `yolo_switcher`/`read_project_dotenv` that was explicitly `false` being turned back on — is warned about by name, alongside the value it replaces (both booleans default to `true` upstream, so writing `true` over an unset key grants nothing and says nothing), and a key rulesync does not know is named too rather than passed through silently.\n>\n> The same override carries an `extensions` block, merged into `[extensions]` the same way. dcode auto-loads `*.py` from your `~/.deepagents/extensions/` **and** from a checked-out project's `.deepagents/extensions/`, so two keys decide whether repository-authored Python is imported into the agent process: `enabled` (the switch above the whole extension system, `true` upstream) and `trust` (`ask` / `always` / `never`, the fallback applied to project extension code when no per-project grant exists, `ask` upstream). On **import**, exactly those two keys come back, with `trust` read the way upstream's `parse_trust_policy` reads it — trimmed and lowercased, so `\" Always \"` is lifted as `always` rather than dropped — and a value dcode itself would not read is left behind with a warning, as in the `startup` block. `extensions.extra_paths` is not modeled: it names machine-local files and directories, which do not belong in a committed permissions file, and it only ever widens what loads — it is not lifted on import, though a hand-written one still passes through on generate like any other unknown key, named in a warning. Because this block too lands in your global config, `trust` of `always`, or an `enabled` that was explicitly `false` being turned back on, is warned about by name alongside the value it replaces. Example:\n>\n> The override's `mcp` block is merged into `[mcp]` the same way and carries one modeled key, `disabled_project_servers`: the names of project MCP servers — the ones a project's `.mcp.json` / `.deepagents/.mcp.json` declares, which Rulesync's `mcp` feature writes — that dcode **always rejects**, a rejection winning over any approval or trust. dcode reads this list from the user-level `config.toml` only, never from a project file, which is why it is authored here rather than beside the MCP config. It is a canonical-shaped `string[]`; on **import** it comes back as the array dcode resolves the value to, so a comma-separated string dcode also accepts (`\"a, b\"`) is lifted as `[\"a\", \"b\"]`, names are trimmed, empty ones dropped, and a non-string element of a list is dropped while the names around it survive — a value that is neither a string nor a list is left behind with a warning, as in the other blocks. The list is written as a whole, so a shorter list from a repository's permissions file would lift a rejection the machine had in place: every name it removes from the existing `[mcp].disabled_project_servers` is warned about by name. `enabled_project_server_approvals` — dcode's own store of the project servers you approved at the prompt, each bound to a repository or worktree and a definition fingerprint — is never lifted on import and is dropped on generate, so a repository cannot pre-approve its own servers through a committed permissions file; `disabled_servers` (the server viewer's toggle) and the deprecated, ignored `enabled_project_servers` are not modeled and pass through like any other unknown key, named in a warning. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"deepagents\": {\n> \"startup\": { \"mode\": \"auto\", \"yolo_switcher\": false },\n> \"extensions\": { \"enabled\": true, \"trust\": \"never\" },\n> \"mcp\": { \"disabled_project_servers\": [\"filesystem\"] }\n> }\n> }\n> ```\n\nFor the Antigravity IDE, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the committable workspace `.antigravity/settings.json` (**project mode only**). Antigravity 2.0 evaluates these `Deny > Ask > Allow` and uses `action(target)` entries; rulesync maps canonical categories onto the IDE action vocabulary: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the IDE-only `execute_url` / `unsandboxed` actions have no canonical equivalent and pass through verbatim). A `bash` → `command` pattern is translated, because Antigravity does not read a `command` target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a `regex:` target holds one regex per word, each anchored to its word, so `.*` never spans a space. Literal words become a plain target (`npm run *` → `command(npm run)`). An exact command cannot be expressed: `git status` becomes `command(git status)`, which also matches `git status --short`, and an `allow` or `ask` warns about it. A glob inside a word becomes a per-word regex (`npm run test:*` → `command(regex:^npm$ ^run$ ^test:.*$)`), and each further `*` word needs one word (`docker * *` → `command(regex:^docker$ ^.*$ ^.*$)`). A `*` with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so `git push * --force` and `rm *.env` (which also matches `rm -f foo.env`) cannot be translated. The same goes for a bracket that lists a space, for a `?` or negated bracket that can match one anywhere but at the very end (`git?status` also matches `git status`), and for `{a,b}` alternatives. Such an `allow` or `ask` is skipped with a warning (a skipped `ask` runs without a prompt when Antigravity is set to always proceed). Such a `deny` fails closed instead: it is widened to its leading literal words with a warning (`git push * --force` → `command(git push)`, `rm *.env` → `command(rm)`), so it also blocks commands it was not meant to. Antigravity evaluates `Deny > Ask > Allow`, so an overlapping `allow` such as `git push *` → `command(git push)` cannot let `git push origin main --force` run. Only a deny with no leading literal word (`* --force`, `git?status`) is still skipped, and its warning says it is not enforced. A pattern already written as `regex:...` passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (`regex:^ls( -la)?$`), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000, and refuses a class escape at the end of a bracket range (`[a-\\d]`). Rulesync checks this conservatively and skips such a rule with a warning. Other tools do not read a `regex:` pattern, so put it in the tool-scoped `antigravity-ide.permission` / `antigravity-cli.permission` block of `.rulesync/permissions.jsonc` rather than the shared `permission` block, which every target receives. Import reverses this: a plain target `X` reads as `X *` (or, when it holds `*`, `?`, `[`, `{` or `\\`, as an escaped regex, since a plain target is literal), and a per-word regex that only uses `.*` and escapes reads as its glob. Import warns when the result may differ from what Antigravity enforces: a `regex:` word without `^...$` anchors is read as anchored, so the imported glob may be narrower if Antigravity matches it unanchored, and a plain target holding those glob characters (most likely the verbatim output of an older Rulesync) becomes a literal-match regex that no other tool enforces. File and URL targets are not globs either. A `read_file` / `write_file` target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so `src/**` → `read_file(src)`, the workspace root (`./`, `./**`) → `read_file(.)`, a plain path is kept, and `*` / `**` become the bare action. A path that is `~` or starts with `~/` or a `$NAME` variable, or holds a `${NAME}` reference, is kept as written but warned about, because Antigravity documents only workspace-relative and absolute paths and may not expand it, so a deny written that way may block nothing. A `read_url` target is a domain and covers its subdomains, so a URL or `domain:` pattern becomes its hostname (`https://example.com/*` → `read_url(example.com)`) and `https://*` becomes the bare `read_url`; because the URL path is ignored and subdomains are covered, an `allow` or `ask` warns that it covers the whole domain and its subdomains (`*.example.com` included, since the target also covers `example.com` itself). Any other glob (`**/*.env`, `src/*`) has no Antigravity spelling, so the rule is skipped with a warning — for a `deny`, the warning says it is not enforced. These targets import verbatim. Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` file holds other workspace settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. The User-scope settings file is a platform-dependent VS-Code-style path outside rulesync's home-relative global model, so **global mode is not supported**; the workspace file is intended to be checked into git. See the [Antigravity permissions docs](https://antigravity.google/docs/permissions).\n\nFor the Antigravity CLI (`agy`), this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the global `~/.gemini/antigravity-cli/settings.json` (**global mode only**). The CLI shares Antigravity 2.0's Fine-Grained Permissions Engine with the IDE, so the same `action(target)` vocabulary and `Deny > Ask > Allow` precedence apply: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the engine-only `execute_url` / `unsandboxed` actions pass through verbatim). A `bash` → `command` pattern is translated, because Antigravity does not read a `command` target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a `regex:` target holds one regex per word, each anchored to its word, so `.*` never spans a space. Literal words become a plain target (`npm run *` → `command(npm run)`). An exact command cannot be expressed: `git status` becomes `command(git status)`, which also matches `git status --short`, and an `allow` or `ask` warns about it. A glob inside a word becomes a per-word regex (`npm run test:*` → `command(regex:^npm$ ^run$ ^test:.*$)`), and each further `*` word needs one word (`docker * *` → `command(regex:^docker$ ^.*$ ^.*$)`). A `*` with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so `git push * --force` and `rm *.env` (which also matches `rm -f foo.env`) cannot be translated. The same goes for a bracket that lists a space, for a `?` or negated bracket that can match one anywhere but at the very end (`git?status` also matches `git status`), and for `{a,b}` alternatives. Such an `allow` or `ask` is skipped with a warning (a skipped `ask` runs without a prompt when Antigravity is set to always proceed). Such a `deny` fails closed instead: it is widened to its leading literal words with a warning (`git push * --force` → `command(git push)`, `rm *.env` → `command(rm)`), so it also blocks commands it was not meant to. Antigravity evaluates `Deny > Ask > Allow`, so an overlapping `allow` such as `git push *` → `command(git push)` cannot let `git push origin main --force` run. Only a deny with no leading literal word (`* --force`, `git?status`) is still skipped, and its warning says it is not enforced. A pattern already written as `regex:...` passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (`regex:^ls( -la)?$`), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000, and refuses a class escape at the end of a bracket range (`[a-\\d]`). Rulesync checks this conservatively and skips such a rule with a warning. Other tools do not read a `regex:` pattern, so put it in the tool-scoped `antigravity-ide.permission` / `antigravity-cli.permission` block of `.rulesync/permissions.jsonc` rather than the shared `permission` block, which every target receives. Import reverses this: a plain target `X` reads as `X *` (or, when it holds `*`, `?`, `[`, `{` or `\\`, as an escaped regex, since a plain target is literal), and a per-word regex that only uses `.*` and escapes reads as its glob. Import warns when the result may differ from what Antigravity enforces: a `regex:` word without `^...$` anchors is read as anchored, so the imported glob may be narrower if Antigravity matches it unanchored, and a plain target holding those glob characters (most likely the verbatim output of an older Rulesync) becomes a literal-match regex that no other tool enforces. File and URL targets are not globs either. A `read_file` / `write_file` target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so `src/**` → `read_file(src)`, the workspace root (`./`, `./**`) → `read_file(.)`, a plain path is kept, and `*` / `**` become the bare action. A path that is `~` or starts with `~/` or a `$NAME` variable, or holds a `${NAME}` reference, is kept as written but warned about, because Antigravity documents only workspace-relative and absolute paths and may not expand it, so a deny written that way may block nothing. A `read_url` target is a domain and covers its subdomains, so a URL or `domain:` pattern becomes its hostname (`https://example.com/*` → `read_url(example.com)`) and `https://*` becomes the bare `read_url`; because the URL path is ignored and subdomains are covered, an `allow` or `ask` warns that it covers the whole domain and its subdomains (`*.example.com` included, since the target also covers `example.com` itself). Any other glob (`**/*.env`, `src/*`) has no Antigravity spelling, so the rule is skipped with a warning — for a `deny`, the warning says it is not enforced. These targets import verbatim. Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` holds other CLI settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. Five CLI-only autonomy/sandbox knobs outside the allow/ask/deny arrays can be authored (and round-trip) through an optional `antigravity-cli` override block in `.rulesync/permissions.jsonc`: `toolPermission` (the global autonomy preset — `request-review` (default) / `proceed-in-sandbox` / `always-proceed` / `strict`), `enableTerminalSandbox` (a boolean confining agent-run commands to OS containment), `artifactReviewPolicy` (whether the agent's artifact changes are gated on a review prompt — `asks-for-review` (default) / `agent-decides` / `always-proceed`), `allowNonWorkspaceAccess` (a boolean, off by default, letting the agent read or write files outside the active workspace roots), and `agentMode` (the baseline execution mode a session starts in — `default` / `accept-edits` / `plan`). Antigravity applies the allow/deny lists as per-rule exceptions to the preset at runtime, so rulesync authors these keys verbatim as top-level siblings of `permissions` with no precedence modeling. This override is **CLI-only** — the Antigravity IDE exposes the same concepts through a GUI with no documented JSON schema, so it does not apply to `antigravity-ide`. Example: `{ \"permission\": { … }, \"antigravity-cli\": { \"toolPermission\": \"strict\", \"enableTerminalSandbox\": true, \"artifactReviewPolicy\": \"agent-decides\", \"allowNonWorkspaceAccess\": false, \"agentMode\": \"accept-edits\" } }`. Verified against the [Antigravity CLI reference](https://antigravity.google/docs/cli/reference), [sandbox docs](https://antigravity.google/docs/cli/sandbox), [settings reference](https://antigravity.google/docs/cli/settings) and [execution modes](https://antigravity.google/docs/cli/modes). See the [Antigravity CLI permissions docs](https://antigravity.google/docs/cli-permissions).\n\nFor Rovo Dev CLI, this generates the `toolPermissions` block of `config.yml` — the global `~/.rovodev/config.yml`, and in project mode the repo-committed `.rovodev/config.yml` that the [Bitbucket Cloud Agentic Pipelines guide](https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/) documents (referenced from `bitbucket-pipelines.yml` via `config.path`, or the `--config-file` CLI flag); the project file is deliberately **not** gitignored, since committing it is how Rovo Dev permissions get enforced in CI. Rovo Dev's three levels (`allow`/`ask`/`deny`) are an exact 1:1 with rulesync's canonical actions, so action values pass through verbatim. The `bash` category maps the catch-all `*` pattern to `bash.default` and every other pattern to a `bash.commands[]` entry `{ command: <pattern as regex>, permission }` (Rovo Dev matches commands as regexes, so author `bash` patterns accordingly). The `read` category maps to the inspection tools (`open_files`, `expand_code_chunks`, `expand_folder`, `grep`) and `edit`/`write` to the mutation tools (`find_and_replace_code`, `create_file`, `delete_file`, `move_file`), written under **`toolPermissions.tools`** — the depth Rovo Dev documents. (Earlier Rulesync versions wrote them one level up, directly under `toolPermissions`, where Rovo Dev ignores them; import still reads that legacy shape as a fallback for keys the nested block says nothing about, so an old file is not lost, and a regenerate deletes the stale copies.) Because these per-tool keys hold a single level (no per-pattern rules), only the catch-all `*` of each category sets the level. Rovo Dev rewrites a single tool key when the user answers \"always allow\" to one prompt, so the four keys of a category can disagree; import collapses them back onto one catch-all by taking the strictest level (`deny` > `ask` > `allow`) rather than whichever key is read last. Rovo Dev's planning and Atlassian tools split the same way, so they ride the same two categories rather than getting one of their own: `read` also reaches `getJiraIssue` and `getConfluencePage`, and `edit`/`write` also reach `createJiraIssue`, `updateJiraIssue`, `createConfluencePage`, `updateConfluencePage` and `createTechnicalPlan` (grouped with the mutating tools because it is the planning tool that produces an artifact rather than reading one). Bear that in mind when authoring: an `edit: deny` reaches Jira and Confluence, not just the working tree. Because `edit` and `write` both map onto the same mutation tools, a conflicting catch-all between them cannot be represented; the stricter of the two levels is kept — the same `deny` > `ask` > `allow` rule import uses — and a warning is logged. Non-catch-all `allow` paths in those categories are surfaced as `allowedExternalPaths` so explicit grants are not dropped; non-`allow` non-catch-all rules cannot be expressed per-path and are skipped with a warning. Categories without a clean Rovo Dev target (e.g. `webfetch`) are skipped with a warning. `config.yml` holds all of Rovo Dev's settings (`agent`, `sessions`, `mcp`, etc.), so the `toolPermissions` block is merged in place — every other top-level key is preserved, as is any key inside `toolPermissions` that Rulesync does not manage — including tools inside `toolPermissions.tools` that no canonical category maps to, and the `toolPermissions.bash` sub-keys Rulesync never writes: `env` (the `${VAR}` passthrough that is the documented way to give a CI run a secret whose name matches Rovo Dev's `token|key|password|secret|auth|credential` filter) and `runInSandbox`. Only the `bash.default` and `bash.commands` leaves are rewritten — the `bash` map itself is merged, not replaced. Because those sub-keys now survive every generate rather than being cleared by one, treat them as durable state: the project `config.yml` is committed, so prefer `${VAR}` references over literal credentials in `bash.env`, and note that a `runInSandbox: false` committed to the file stays in force — a regenerate is not a way to reset it, and says so with a warning when it carries one through. On **import**, a tool key the file is silent about counts as the implicit fallback level (`toolPermissions.default`, or Rovo Dev's own `ask`) rather than as absent, and the category still collapses to the strictest of the set. That matters because Rovo Dev writes a single key when the user answers \"always allow\" to one prompt: without the fallback, one such answer about `create_file` would import as a blanket `edit: allow`, and the next generate would hand that grant to every other tool of the category — Jira and Confluence writes included. A category the file says nothing about at all is still skipped rather than invented.\n\n**Migration note.** `toolPermissions.default` and the seven planning/Atlassian keys became Rulesync-owned in the release that added them. Ownership means the first generate after upgrading removes a hand-written value for one of them unless `.rulesync/permissions.*` produces it — a hand-written `tools.createJiraIssue: deny` or `default: deny` with no matching rule in the rulesync source is dropped (with a warning naming each key), falling back to Rovo Dev's `ask`. Run `rulesync import --targets rovodev --features permissions` before the first generate to carry those values into the rulesync source.\n\nThe canonical all-tools category `*` maps to `toolPermissions.default`, the level Rovo Dev falls back to for any tool with no more specific setting (Rovo Dev's own default is `ask`) — derived from its catch-all exactly as `bash.default` is derived from `bash`'s, and round-tripped back on import. A `*` catch-all `deny` also covers shell commands, so it overrides a `bash` catch-all `ask` and `bash.default` becomes `deny` (`deny` > `ask` > `allow`). The default is a single level, so a pattern rule inside the `*` category has no counterpart and is skipped with a warning. The keys Rulesync does manage (`default`, `bash.default`, `bash.commands`, `allowedExternalPaths`, and the per-tool keys above) are owned rather than merged: each generate rewrites them from `.rulesync/permissions.*`, so removing a rule there removes it from `config.yml` too (a source stating no rule at all clears them; one whose rules simply have no Rovo Dev counterpart keeps the block's restrictions but strips its grants — an `allow` there is normally a leftover of an earlier generate, and dropping one falls back to Rovo Dev's stricter default, whereas clearing the whole block would relax every level), logging a warning naming each owned key it removes — per-tool levels and `allowedExternalPaths` are written from inside a Rovo Dev session too, by an \"always allow\" prompt answer and the `/directories` command, and a hand-edit to one of those keys — including a path added with the in-session `/directories` command, which writes to `allowedExternalPaths` — is replaced on the next generate (values only — YAML comments and formatting in the existing file are not retained on rewrite) — and the file is never deleted. See the [Rovo Dev CLI settings](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) and [tool permissions](https://support.atlassian.com/rovo/docs/use-tools-in-rovo-dev-cli/) docs.\n\nFor Goose, this generates the `user` block of the global `~/.config/goose/permission.yaml` (**global mode only** — Goose persists per-tool permission overrides only under the home directory and has no project-scoped permissions file). Goose stores permissions as a YAML map of mode key → `{ always_allow, ask_before, never_allow }`, where each field is a list of tool-name strings; rulesync writes the user-set decisions under the `user` key. Action mapping is a 1:1: `allow` → `always_allow`, `ask` → `ask_before`, `deny` → `never_allow`. Tool-name mapping: `bash` → `shell`; every other category passes through verbatim as the Goose tool name, so `edit` and `write` map onto the Developer extension's `edit` and `write` tools, and other tools like `tree`, `read_image` or an MCP extension's `extension__tool` round-trip. Goose matches these entries against the public tool name exactly, and the built-in Developer extension exposes its tools unprefixed; the pre-v1.27.0 names `developer__shell` / `developer__text_editor` are still accepted on import (`developer__text_editor`, which both edited and wrote files, as both `edit` and `write`) but never written; a literal `shell` category is written as `shell` and imports back as `bash`. The generated names therefore need Goose v1.27.0 or later. Because `edit` and `write` are separate Goose tools, an `edit` rule does not cover `write` (which creates and overwrites files): author both to restrict file changes. When two categories land on the same Goose tool (e.g. `bash` and a literal `shell`), the stricter action wins (`deny` > `ask` > `allow`) and a warning is logged. Because Goose permission lists hold **whole tool names** rather than per-command/per-path globs, only a category's catch-all `*` pattern is representable — non-catch-all patterns are skipped with a warning. The `permission.yaml` file is merged in place: the `user` block is owned by rulesync, while every other top-level key (notably the `smart_approve` LLM-decision cache) is preserved, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a `user` block of three empty lists would say nothing (an existing one is still rewritten). See the [Goose tool permissions docs](https://goose-docs.ai/docs/guides/managing-tools/tool-permissions/).\n\nFor the Grok Build CLI (`grokcli`), this generates Grok's Claude-style `[permission]` rule arrays — `allow` / `deny` / `ask` — in the project `./.grok/config.toml` (project mode) or the user `~/.grok/config.toml` (global mode, via `--global`). Grok documents that \"Project configs are limited to MCP servers, plugins, and permission rules, not full user configs\" ([settings docs](https://docs.x.ai/build/settings)), so the fine-grained `[permission]` rules are valid at both scopes. Each canonical `permission.<category>.<pattern>` becomes a Grok entry bucketed into the matching array: `bash`→`Bash`, `read`→`Read`, `edit`→`Edit`, `grep`→`Grep`, `glob`→`Glob`, `webfetch`→`WebFetch`, `websearch`→`WebSearch`, `agent`→`AgentMessage`, and `mcp__<server>__<tool>`→`MCPTool(<server>__<tool>)`; a `*` pattern emits the bare tool name (e.g. `Bash`) and a concrete pattern emits `Tool(pattern)` (e.g. `Bash(git *)`). `write` collapses onto `Edit` (Grok has no separate `Write` tool — a documented lossy mapping). `Glob` is Grok's accepted alias of the `Grep` filter, so a `glob` rule is enforced exactly like a `grep` rule while keeping its own spelling, and on import `Glob(...)` comes back as `glob` and `Grep(...)` as `grep`. `AgentMessage` (added in Grok 1.0.10) is the nearest named Grok filter to the canonical `agent` category, but it governs a narrower operation: it gates only messages sent to an already-running subagent, not the launch of a subagent (Grok checks a `Task` launch as an `Edit` access on an internal `task:<type>` key, which Rulesync does not target). Its pattern is matched against the _target subagent id_ (as a glob or a prefix) rather than the subagent type a Claude Code `Agent(...)` rule names, so a canonical `agent` rule does not stop Grok from starting subagents — it only controls steering messages to them; write an id-shaped pattern when targeting Grok. Grok's legacy spellings `SendSubagentMessage(...)` / `SendAgentMessage(...)` are read on import and re-emitted as `AgentMessage(...)`. The one category with no Grok tool (`notebookedit`) is skipped, with a warning when it carries a `deny` rule. Grok evaluates the arrays with precedence `deny > ask > allow`, which import mirrors (a tool listed in multiple arrays resolves to the strictest action). The coarse `[ui] permission_mode` toggle (`\"ask\"` / `\"always-approve\"`) is still written as a backward-compatible fallback for older Grok versions: `always-approve` when the config is pure-`allow`, otherwise `ask` (conservative — never `always-approve` while any `deny`/`ask` rule exists, so it never contradicts the fine-grained arrays). Grok's third mode, `auto` (classifier-based, toggled in the TUI with `/auto`), is an exception in both directions: nothing in the canonical model derives it, so Rulesync never writes it — and a `config.toml` that already selects it keeps it, since overwriting would silently downgrade the user to `ask` on every `generate --global`. The fine-grained arrays are still written in that case; only the coarse toggle is left alone. On import, both documented `[permission]` forms are parsed back into canonical categories: the compact `allow`/`deny`/`ask` arrays and the verbose `[[permission.rules]]` tables (`{ action = \"allow\", tool = \"bash\", pattern = \"git *\" }`). The verbose `tool` field is documented lowercase (`any`/`bash`/`edit`/`read`/`grep`/`mcp`/`webfetch`/`websearch`/`agent_message`) while the compact entries are capitalized, so it is matched case-insensitively and `mcp` folds into the canonical `mcp__…` categories exactly as `MCPTool(…)` does; a rule with no `pattern` covers the whole tool. Rules from the two forms merge with the same `deny > ask > allow` precedence, and a rule naming a tool with no canonical category (e.g. `any`) is skipped. The compact arrays also accept Claude Code's `mcp__<server>[__<tool>]` spelling, which import reads like the matching `MCPTool` entry: `mcp__*` → `mcp`, a server-only `mcp__github` → `mcp__github__*`, and `mcp__github__get_issue` → `mcp__github__get_issue`; generate writes them back as `MCPTool(...)`. Only when neither form carries a rule do we fall back to the coarse mode (`always-approve` ⇄ `bash: { \"*\": \"allow\" }`, `ask`/unset ⇄ `bash: { \"*\": \"ask\" }`). Generate always writes the compact arrays. `config.toml` is shared with the MCP feature, so rulesync owns the `[permission]` `allow`/`deny`/`ask` arrays and `[ui] permission_mode` while every other key (e.g. `[mcp_servers]`, `[sandbox]`) is preserved, and the file is never deleted — including a hand-authored verbose `rules` array, which is read on import but left untouched on generate rather than reconciled against the arrays rulesync writes. **Migration:** a `config.toml` written by an earlier Rulesync may carry hand-authored `WebSearch`, `Glob`, `AgentMessage` (or legacy `SendSubagentMessage` / `SendAgentMessage`) or Claude-style `mcp__…` entries that were preserved verbatim as unmanaged; they are now parsed into the canonical `websearch` / `glob` / `agent` / `mcp…` categories and treated as Rulesync-owned on generate, so an entry without a matching canonical rule is dropped rather than kept verbatim. See the [Grok CLI settings reference](https://docs.x.ai/build/settings/reference) and [modes docs](https://docs.x.ai/build/modes-and-commands).\n\nFor Vibe (mistral-vibe), this generates per-tool `[tools.<tool>]` tables in the shared `.vibe/config.toml` (project mode) or `~/.vibe/config.toml` (global mode). Tool-name mapping: `bash` → `bash`, `read` → `read_file`, `edit` → `edit`, `write` → `write_file`, `webfetch` → `web_fetch`, `websearch` → `web_search`, `grep` → `grep`, `agent` → `task`. These are Vibe's builtin tool names (`BaseTool.get_name()`, the snake_case of each tool class); `edit` and `write_file` are distinct tools — `write_file` has been create-only since v2.14.0 — so the two canonical categories no longer collapse onto one name. **Migration:** a `config.toml` written by an earlier Rulesync may still carry `write_file` entries derived from the `edit` category, or inert `[tools.fetch]` / `[tools.search_web]` / `[tools.agent]` blocks. Rulesync only rewrites the names it now emits, so remove those stale entries by hand — a leftover `disabled_tools = [\"write_file\"]` keeps Vibe's `write_file` disabled even though no canonical rule asks for it, and inert `[tools.glob]` / `[tools.notebookedit]` tables an earlier Rulesync emitted for tools Vibe does not have stay on disk until removed by hand (new generates skip those categories instead of rewriting them). Within a category, the catch-all `*` pattern sets the per-tool `permission` (`allow` → `always`, `ask` → `ask`, `deny` → `never`); a wildcard deny additionally adds the tool to the top-level `disabled_tools` filter. A wildcard allow deliberately does **not** touch the top-level `enabled_tools` key: upstream treats it as an **exclusive** allowlist (“if set, only these tools will be active”), so expressing allows through it — as earlier Rulesync versions did — silently switched off every other builtin and MCP tool; the per-tool `permission = \"always\"` entry carries the allow completely, and a regenerate now removes the exclusive entries an earlier version wrote for the tools it configures (`disabled_tools` entries, by contrast, are only cleared by a category that actually states a base permission with a `*` rule — clearing them for a category holding pattern rules alone would silently promote a disabled tool to “enabled, with a denylist”; on **import**, a `disabled_tools` entry likewise wins over a `[tools.<name>]` table that contradicts it — permission and patterns alike are skipped for that tool, because Vibe applies the filter last and unconditionally: reading the table's `permission = “ask”` (or `always` plus an `allowlist`) as the tool's real state would import a hand-written contradiction as a mere prompt and drop the filter on the next generate, and importing one of its patterns as an `allow` would carry a hole through a switched-off tool into every **other** tool's generated config. The match is on Vibe's own tool name rather than the canonical category, since `name_matches` globs the raw name: a bare `disabled_tools = [“read”]` matches no builtin upstream, so it must not silence `[tools.read_file]`. The entry itself is still imported as a deny for its canonical category, because Rulesync cannot see Vibe's registry — an MCP server may well publish a tool named exactly `read` — and denying something that is already off costs nothing where dropping a real deny would. The match follows upstream's `name_matches`, which trims each entry, skips a blank one, and then applies a case-insensitive `fnmatch` glob over the raw name — so `disabled_tools = [\"read_*\"]`, `[\"READ_FILE\"]` and `[\" read_file \"]` all silence `[tools.read_file]`, while `[\"\"]` silences nothing and is dropped rather than imported as an empty category: the canonical `read` category is denied on import, beside the literal `read_*` entry, which is carried out verbatim so the glob keeps its reach over the tools Rulesync cannot enumerate — an MCP tool registered at run time. A `re:`-prefixed entry, which Vibe matches as a Python regular expression, is deliberately **not** evaluated: Python's regex dialect is not JavaScript's, and a wrong verdict either drops a table's rules or carries a disabled tool out as an allow, so such an entry is matched only by its exact spelling and a warning names the tables that were therefore imported as authored; a glob longer than 256 characters is left alone and reported the same way, since a tool name is short in every registry Vibe reads and matching a kilobyte-long entry against a kilobyte-long table name buys nothing. A glob that _is_ resolved is walked step by step against fnmatch's own rules rather than translated into a JavaScript regular expression: the two dialects disagree about exactly the bracket corners (a `]` straight after `[` is a member to fnmatch and an empty class to JavaScript; an inverted range such as `[z-a]` is not dropped upstream but _closed over_, taking the member before it and the member after it with it, so `[z-a]` matches nothing, `[!z-a]` matches everything, `[b-ax]` is just `[x]`, and `[a--!]` collapses to a bare `!` that then negates an empty class and matches everything), and a translated `*a*a*a*b` backtracks catastrophically against a long tool name where the walk stays linear per `*`. The deny patterns of a table the filter switches off are carried across even though its allow patterns are not: dropping them would lose a deny the file states outright, which is the broadening direction. Where a canonical category is shared — `read_file` and a bare `read` MCP table both land on `read`, as do `task` and `agent` — the blanket deny is re-asserted after every table has been read, so the category is denied whichever order the tables happen to sit in; letting the later table decide made the import depend on that order, and in one of the two orders a tool the file had switched off was imported as a live allow. On **generate**, a glob left in `disabled_tools` is for the same reason never deleted to make room for an allow Rulesync just wrote — it reaches tools Rulesync cannot see, and removing it would switch those back on — so the contradiction is reported with a warning instead, since Vibe applies the filter last and unconditionally — the warning names only the globs that actually reach a table Rulesync wrote, and an entry Rulesync does not work out — a `re:` regular expression, or a glob past the 256-character cap — gets a warning of its own asking you to check it by hand rather than being listed beside tables some other glob matched. Both warnings print each name with its control characters stripped and its width capped at 80 terminal columns, and name at most ten before counting the rest, since the text comes from a `config.toml` that may have been checked in by someone else); specific patterns become **`allowlist` / `denylist`** entries — these are the keys Vibe's permission engine actually reads (`BaseToolConfig`), so the legacy `allow` / `deny` keys are dropped on generate from every table a category writes (still honored on import). A table carrying **both** spellings holds one list Vibe enforces and one it ignores, so both are read as a single list and written back under the canonical key: preferring either alone would drop the other, and on the deny side that silently discards a restriction Vibe was applying — multiplied across all three shell tables by the fan-out below. The two sides are not symmetric: reading both deny lists can only restrict further, while reading both allow lists **promotes** a pattern Vibe was ignoring into one it enforces (an `allowlist` match is unconditionally allowed even under `permission = “never”`), so that promotion is announced with a warning naming the patterns — delete them from `config.toml` if the legacy list was stale. Vibe has no per-pattern `ask`, so pattern-level `ask` rules are skipped with a warning. A canonical category with no Vibe builtin tool at all (e.g. `glob`, `notebookedit`) is likewise skipped with a warning instead of emitting an inert `[tools.<category>]` table — a `deny` written there would look applied while Vibe ignores it. The all-tools `*` category is skipped for a different reason: Vibe's config has per-tool tables only, and while `disabled_tools` does match glob patterns (`name_matches`), an entry there removes the tool from the registry outright rather than acting as a default the sibling `[tools.<name>]` tables can override — so `disabled_tools = [\"*\"]` would silently swallow every `allow` authored next to the wildcard. A category that is **not** a canonical Rulesync category is taken at face value as a Vibe tool name and written to `[tools.<name>]`: Vibe's tool manager resolves that table for every registered tool rather than only the builtins, so this is how per-MCP-tool permissions are authored, and `vibe.permission.<name>.sensitive_patterns` works there too. Because Vibe publishes an MCP tool as `<server>_<tool>`, the cross-tool canonical spelling `mcp__<server>__<tool>` is translated to that name on generate (`mcp__github__create_issue` → `[tools.github_create_issue]`; only the first `__` is split, so a tool name may itself contain one). The translation is one-way — Vibe's name carries no `mcp` marker — so import keeps `github_create_issue` as the category; regenerating from it writes the same table. A server-scoped `mcp__<server>` category is skipped with a warning, since Vibe has no server-level permission table; so is a wildcard one (`mcp__<server>__*`, `mcp__*__<tool>`), because a `[tools.<name>]` lookup is exact even though `disabled_tools` entries are glob-matched, which would make `[tools.\"github_*\"]` an inert deny. Since a non-canonical name is written verbatim, a misspelled builtin (`powershel`) becomes an inert table; case mistakes (`Bash`), a mis-cased MCP prefix (`MCP__github__x`) and a glob in the name (`github_*`, whose pattern-level rules Vibe never looks up because `[tools.<name>]` is matched exactly) are the ones detectable without guessing, and those are warned about. The `bash` category is written to three tables — `bash`, `git_bash` and `powershell` — because Vibe's managed shell is a different tool on each platform: the POSIX one publishes `bash`, but on Windows it is `git_bash` or `powershell`, so a `bash` deny landing only on `[tools.bash]` left the shell fully allowed there. Author `git_bash` or `powershell` as its own category in the shared `permission` block to override the fan-out for that shell. A `vibe.permission.<shell>` entry does **not** claim it for the base permission: that block carries `sensitive_patterns` only, so treating it as a claim would strip the `bash` permission from the shell without putting anything in its place — its patterns are merged onto the fanned-out table instead, and a `vibe.permission.<shell>` entry does keep `vibe.permission.bash`'s patterns from overwriting the ones authored for that shell. A shell category that expresses nothing Vibe can read (only pattern-level `ask` rules, or no rules at all) likewise does not claim the shell, since it would otherwise cancel the `bash` deny and write nothing in its place. The fan-out also stands down for a shell the existing `config.toml` already configures differently from `[tools.bash]`, and warns when it does — that is a permission decision made outside the `bash` category, and overwriting it could broaden a `permission = \"never\"` into whatever the canonical `bash` category says. Standing down costs that shell only its **base permission and allow patterns**: the `bash` category's `deny` patterns are still merged into the shell's `denylist` (and its legacy `deny` key folded into the canonical one), because Vibe resolves a denylist match before the allowlist and before the configured permission, so those entries can only restrict the shell further — leaving them out would let a deny you authored go silently missing on one of the three shells. A shell that exists only as a `disabled_tools` entry is skipped there: it is off the registry entirely, so a `denylist` for it would be inert, and writing one would invent a table you never authored. A shell that has its own category in the shared `permission` block is neither stood down from nor reported: that category owns the table outright. The one exception is a `bash` category whose `*` rule is a **deny**: that cannot broaden anything — the shell ends up disabled outright, which is at least as strict as whatever it held — and standing down there would leave an authored deny silently absent from one of the three shells, the exact failure the fan-out exists to prevent. A wildcard deny therefore overwrites the shell (with a warning, which also says that the shell's own `allowlist` / `denylist` entries are replaced along with its permission — the shell ends up disabled outright, so nothing it held is still enforced). Author that shell as its own category to keep a different permission for it. Only the permission keys the fan-out itself mirrors take part in that comparison (`permission`, `allowlist`, `denylist` — with the legacy `allow` / `deny` spellings normalized to the canonical ones — plus `disabled_tools` membership), so a key outside it on `[tools.bash]` — a `timeout`, say, which is carried over for `bash` but never copied to the aliases — does not make Rulesync's own output look hand-authored and freeze the fan-out on the next generate. `sensitive_patterns` is outside it for the same reason: that key is written by the `vibe.permission` pass, which addresses each shell **by name**, so a `vibe.permission.git_bash` entry legitimately leaves that shell holding patterns `[tools.bash]` does not have — counting it would read Rulesync's own output as an outside decision and freeze the fan-out, and mirroring it would overwrite the per-shell patterns you asked for. It is still **filled in** where a shell has none of its own, so a hand-authored `[tools.bash] sensitive_patterns` guard travels with the permission it guards instead of the shells receiving the bare allow. The copy keeps the order it was authored in: `[tools.bash]`'s own list is a key Rulesync does not write, so it is left alone, and sorting only the copy made one guard read as two different lists across the three shells. The fill is one-way — it never clears a shell's patterns — so deleting the `[tools.bash]` guard afterwards leaves the copies behind on the Windows shells; that asymmetry is reported with a warning, because an import would otherwise read them as per-shell rules and write `vibe.permission.<shell>.sensitive_patterns` entries you never authored, which claim those shells out of the fan-out for good. Values are compared as sets, not as written: `denylist = [\"b\", \"a\"]` beside `denylist = [\"a\", \"b\"]` is the same decision, and treating the order as a divergence would strand a `bash` deny on a shell that already agrees with it. A table holding none of those keys — or holding one only as an empty list, which is the absent key spelled out (and which Rulesync drops from its output rather than carrying over) — states no decision and is fanned out over; a key whose value is not a list at all (`denylist = “rm -rf *”`) does count as a decision, even though Vibe's own `BaseToolConfig` types it as `list[str]` and fails to load the file — of the two ways to be wrong about it, deleting the key is the one you cannot recover from (the `bash` deny merge stands down from such a table too, with a warning, since merging into a non-list would replace it rather than add to it — and in that case, as when the `bash` category carries no `deny` patterns at all, the stand-down warning says that **nothing** from the category reaches the shell rather than promising a merge that does not happen); the stand-down warning is likewise only raised when there is a `bash` permission to stand down — when only a `vibe.permission.bash` entry exists, the warning says that its `sensitive_patterns` are what does not reach the shell. The fan-out is also skipped when the `bash` category itself expresses nothing Vibe can read: with no permission to spread, mirroring would push whatever `[tools.bash]` already carries onto shells the file never configured, silently broadening them. When the fan-out does run it **mirrors** `[tools.bash]`'s permission keys onto the two aliases rather than merging each alias with its own previous contents (their unmanaged keys are kept): merging would diverge them the first time `[tools.bash]` carried an entry the aliases lacked — which Rulesync's own output does as soon as a hand-authored `denylist` is merged into `bash` — and that divergence would then stand the fan-out down forever, stranding every later `bash` deny on POSIX. Mirroring is safe precisely because a shell stating a decision of its own has already been excluded, and it makes regenerating idempotent. `disabled_tools` membership is mirrored too, for the same reason. `enabled_tools` membership is deliberately excluded: it is an exclusive registry filter rather than a permission, and counting it let `enabled_tools = [\"powershell\"]` take that shell out of a `bash` deny while leaving it the only active tool. On import, a Windows shell table identical to `bash` is collapsed back into the single `bash` category, while one that differs is kept as its own. Unknown `[tools.*]` tables already on disk still round-trip untouched. The `config.toml` file is shared with the MCP feature, so writes merge non-destructively and the file is never deleted. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/tools/base.py`).\n\n> **Vibe-only override (`vibe` key):** Vibe's `BaseToolConfig` also carries a `sensitive_patterns` list — patterns that escalate to **ASK even when the base permission is ALWAYS** (allow). The canonical model can only set a pattern to a single `allow`/`ask`/`deny`, so an \"allow by default but ask on these patterns\" escalation cannot be expressed in the shared block. Add a tool-scoped `vibe` override to author it: `vibe.permission.<category>.sensitive_patterns` carries the list per canonical category (e.g. `bash`, `edit`), while the shared `permission` block still sets the base permission and allow/deny lists. On import, a tool's `sensitive_patterns` round-trips back into the `vibe` override (the base allow stays in the shared block). rulesync owns the list for any category named in the override (a present list is set, an empty one clears it); categories not named keep whatever the existing `config.toml` had. A `vibe.permission.bash` entry is fanned out to the Windows shells like the base permission is, but where a shell's `config.toml` table already sets **different** patterns of its own it **merges** into them instead of replacing them, and warns that it did: those patterns are what still escalates to ASK once the base permission becomes ALWAYS, so they are the author's only remaining defense there, while dropping the override's patterns would strand a newly added guard on that one shell. A union is safe in both directions because `sensitive_patterns` only ever escalates to ASK. An empty `sensitive_patterns = []` counts as no patterns of the shell's own — it is the absent key spelled out, and Vibe's own config dump writes it that way — so the override's list is applied there in full. The reverse case is the exception to “an empty list clears it”: an empty `vibe.permission.bash.sensitive_patterns` clears the patterns this override owns, but a shell that authored **different** patterns of its own keeps them (with a warning saying so) — delete them from `config.toml` to clear them. Name that shell in `vibe.permission.<shell>.sensitive_patterns` to own its list outright. `sensitive_patterns` is the only key this override can express — a `permission`, `allowlist` or `denylist` written inside `vibe.permission.<category>` is ignored with a warning, since the shared `permission` block is where those belong and the base permission it sets can be the exact opposite of what the ignored key asked for. The override also carries `vibe.enabled_tools` — the only way to author Vibe's top-level **exclusive** allowlist. The list is written verbatim in Vibe's tool-name vocabulary (declaring it, even empty, makes rulesync own the whole key), and on import a non-empty `enabled_tools` is lifted back into the override rather than being misread as a set of `\"*\": \"allow\"` grants. Note the `config.toml` scope semantics: since v2.24.0 Vibe installs the user and project TOML layers **together** rather than picking one, so a trusted project config overlays the user config instead of replacing it, and a `--global` run is no longer discarded wholesale by a project `config.toml` — the project layer still wins key by key where it sets one, but every key it leaves unset falls through to the global file. Merging is per key: `mcp_servers` and `connectors` union-merge by name, `tools` deep-merges, `disabled_tools` concatenates, and `enabled_tools` is replaced wholesale by the higher layer. An org-enforced `AdminConfigLayer` sits above every layer at runtime and can override anything below it. (Whether a project layer is read at all still depends on Vibe trusting the project, so treat the overlay as the trusted-project behavior.)\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"*\": \"allow\" } },\n> \"vibe\": { \"permission\": { \"bash\": { \"sensitive_patterns\": [\"rm *\", \"sudo *\"] } } }\n> }\n> ```\n\nFor Takt, this generates the `default_permission_mode` under `provider_profiles.<provider>` in the shared `.takt/config.yaml` (project mode) or `~/.takt/config.yaml` (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered `readonly` < `edit` < `full` (`readonly` may only read, `edit` may also edit/write files, `full` may also run shell commands). The active provider is resolved from `runtime.yaml` first when Takt is in runtime provider mode (see below) — the provider of the profile named by `provider.defaults.profile`, with no fallback, matching Takt (a lone profile is not promoted to the default, and `defaults.pool`/ladder forms resolve at run time) — and otherwise from the top-level `provider:` key of `config.yaml` (a plain name or the `provider: { type: … }` block form), then — on a project-mode generate whose file names none — the top-level `provider:` of the global `~/.takt/config.yaml`, as Takt itself resolves `provider` local → global → default; failing both, Rulesync falls back to its own heuristic of the sole `provider_profiles` entry, and then to the `claude-sdk` default. Import reads only the file it was pointed at and does not consult the global one, and a generate that inherits from the global file depends on that machine's `~/.takt/config.yaml`, so commit an explicit `provider:` in the project config when the output has to be the same everywhere (for example under `generate --check` in CI). That default follows Takt 0.68.0, which made the Claude Agent SDK the default provider and looks permission profiles up by the selected provider name without resolving the `claude` alias: with no provider configured Takt reads `provider_profiles.claude-sdk`, while an explicit `provider: claude` still reads `provider_profiles.claude`. A lone `claude` profile with no `provider:` key is not promoted by the sole-profile rule, because earlier Rulesync versions wrote it as their own fallback: the mode is written to `claude-sdk` instead, the old `claude` profile is left in place, and import reads the (usually absent) `claude-sdk` profile. On an older Takt with no `provider:` key, set `provider: claude` explicitly so the generated mode lands in the profile that version uses. `provider_profiles` itself stays in `config.yaml` in every version: it is not a legacy provider signal, and Takt does not move permission modes into `runtime.yaml`. The mapping is therefore **lossy**: on generate, a single mode is derived with this precedence — (1) any `deny` rule anywhere ⇒ `readonly` (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any `edit`/`write` category `allow` rule ⇒ `edit`; (3) else any `bash` category `allow` rule ⇒ `full`; (4) else ⇒ `readonly` (safe default). On import, `full` ⇄ `bash: { \"*\": \"allow\" }`, `edit` ⇄ `edit: { \"*\": \"allow\" }`, and `readonly` (or an unset/unknown mode) ⇄ `bash: { \"*\": \"deny\" }`. `config.yaml` is shared with other Takt settings, so the mode is merged in place — every other provider profile and all other top-level keys are preserved — and the file is never deleted. Takt's default-deny **workflow security policies** — `workflow_arpeggio` (`custom_data_source_modules`, `custom_merge_inline_js`, `custom_merge_files`), `workflow_runtime_prepare.custom_scripts`, `workflow_command_gates.custom_scripts`, `sync_conflict_resolver.auto_approve_tools`, and the `allow_git_hooks` / `allow_git_filters` booleans — have no canonical permission category, so they are authored through the `takt` override block of `.rulesync/permissions.*` and round-trip on import. Each admits one class of user-supplied code, so only the exact shapes Takt itself accepts are written: a sub-key Takt does not declare is dropped with a warning rather than passed through, since Takt's schemas are strict and reject the whole file on an unknown key, while a value of the wrong type fails when `.rulesync/permissions.*` is read. Removing one of these keys from `config.yaml` because the source no longer states it is warned about too — including a key put there by hand, which owning them implies. Deleting `.rulesync/permissions.*` altogether is different: the feature has no source to generate from, so nothing runs and whatever is in `config.yaml` stays. These keys are also authoritative rather than merged — revoking one in `.rulesync/permissions.*` removes it from `config.yaml`, instead of leaving the capability switched on. `workflow_mcp_servers` stays with the MCP feature, which derives it from the transports in use.\n\nTwo Takt-specific surfaces with no canonical category can be authored (and round-trip) through an optional `takt` override block in `.rulesync/permissions.jsonc`: `step_permission_overrides` (a per-workflow-step map `<step>` ⇒ `readonly`/`edit`/`full`, written inside the active provider profile and layered by Takt on top of `default_permission_mode`) and `provider_options` (a top-level, per-provider table of sandbox/network knobs orthogonal to the mode, e.g. `codex.network_access`, `claude.sandbox.allow_unsandboxed_commands`, `opencode.allowed_tools`). Example: `{ \"permission\": { … }, \"takt\": { \"step_permission_overrides\": { \"ai_review\": \"readonly\" }, \"provider_options\": { \"codex\": { \"network_access\": true } } } }`. Note the workflow-step `required_permission_mode` floor is a field of the **workflow YAML**, not `config.yaml`, so it is intentionally out of scope (Takt's config loader hard-rejects unknown top-level keys).\n\n**`provider_options` and Takt 0.56.0 (`runtime.yaml`).** From 0.56.0, provider configuration lives in `runtime.yaml` (`.takt/runtime.yaml` for the project, `~/.takt/runtime.yaml` globally), and \"runtime provider mode\" is active as soon as its `provider:` section carries an actual assignment — a non-empty `defaults`, `profiles` or `auto_routing`, a `targets` map with at least one non-empty entry, or a named `assignments` set with a non-empty `defaults` or such a `targets` map. A file holding only `version: 1`, or empty maps such as `defaults: {}`, is inactive and leaves the legacy resolution in place. Companion reviewers are opt-in (`companion.enabled` defaults to `false`), and while they are off Takt evaluates the section without its `targets.companions` maps and without the profiles only those reach through `extends` — so a file that merely routes companions is inactive too, and a profile is ignored (its `options` are not lifted on import) unless an agent assignment also reaches it. While runtime mode is active, **any** legacy provider setting in `config.yaml` — `provider_options` among them — stops Takt with `Mixed provider configuration detected` before it runs an agent, and Takt generates an active `~/.takt/runtime.yaml` on first launch in a fresh environment, so new installs are in runtime mode by default. Rulesync therefore reads `runtime.yaml` — both the scope being generated and the global one, because Takt collects legacy signals from both `config.yaml` files — and merges them the way Takt's loader does before deciding anything: `provider.profiles` and `provider.assignments` are unions in which a project entry replaces the global entry of the same name, while `defaults`, `targets` and `auto_routing` are taken from the project file whole whenever it states them at all, so a project `targets: {}` masks the global one rather than merging with it; `companion.enabled` is on only when neither file turns it off. On that merged document, while runtime mode is active, rulesync **refuses to write `provider_options`** into `config.yaml`, warning instead of quietly emitting a key that would take the install down. Rulesync does not write `runtime.yaml` itself: a profile is provider- **and** scope-specific, and Takt replaces a same-named profile wholesale across scopes rather than merging it field by field, so there is no key rulesync could own there without clobbering the user's provider and model. Author those options yourself under `provider.profiles.<profile>.options` in `runtime.yaml` — a **flat bag applying to that profile's own provider**, so the `codex:` / `claude:` segment of `provider_options` is dropped — and remove `provider_options` from the `takt` block of `.rulesync/permissions.*`. Anything already written into `config.yaml` by hand is left untouched; rulesync does not own that key. On **import**, both sides are read: the legacy `provider_options` table and, in runtime mode, each profile's `options` from the `runtime.yaml` of the scope being imported (import stays inside the tree it was pointed at), re-keyed by the profile's own `provider` (the runtime side wins on a collision), so nothing is lost. One consequence worth knowing: importing a runtime-mode install produces a `takt.provider_options` block that a later generate will refuse and warn about — drop it from the rulesync source once the options are settled in `runtime.yaml`. Installs with no `runtime.yaml`, or an inactive one, keep the pre-0.56.0 behavior unchanged: `provider_options` is written to `config.yaml` exactly as before.\n\nSee the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).\n\nFor Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.config/amp/settings.json` (global mode), using **two** permission surfaces. In rulesync's canonical model the category name **is** the Amp tool name. A **whole-tool deny** (pattern `*`) is written to the bare `amp.tools.disable` array (the tool name is pushed verbatim, preserving `builtin:` prefixes and the `*` glob) for backwards compatibility. Every **lossy** case is written to the ordered `amp.permissions` array instead of being dropped: an **argument-specific deny** (pattern `!== \"*\"`) becomes `{ tool, action: \"reject\", matches: { cmd: <pattern> } }`, and every `allow` / `ask` rule becomes `{ tool, action, matches?: { cmd } }` (the `matches` object is omitted for the `*` catch-all). Amp evaluates `amp.permissions` **first-match-wins**, so generated entries are ordered deterministically and fail-closed: sorted by tool name, then entries **with** `matches.cmd` (more specific) before catch-alls, then by action priority **`reject` < `ask` < `allow`**, then by `cmd`. `amp.permissions` is Amp's **legacy / backwards-compatibility** surface (gone from the web docs, see below) — it remains functional and is the only place to express `allow`/`ask` and argument-specific `reject` rules. **Ownership:** rulesync OWNS and wholesale-replaces the `allow`/`ask`/`reject` entries on every generate, but **preserves any existing `action: \"delegate\"` entry** (rulesync's canonical model has no `delegate` equivalent); preserved `delegate` entries are placed **after** the rulesync-generated entries (so the regenerated rules take precedence under first-match-wins). On **import**, both keys are read and merged into one canonical config: `amp.tools.disable[tool]` → `{ tool: { \"*\": \"deny\" } }`, and each `amp.permissions` entry → `{ tool: { (matches?.cmd ?? \"*\"): mapped } }` (`reject` → `deny`, `allow` → `allow`, `ask` → `ask`; `delegate` is skipped). When both sources target the same tool+pattern, the **most restrictive action wins** (`deny` > `ask` > `allow`). The settings file is shared with the MCP feature (`amp.mcpServers`), so all other keys are preserved on round-trip and the file is never deleted. Tool names and `cmd` patterns that are prototype-pollution keys (`__proto__`, `constructor`, `prototype`) are skipped defensively. The whole settings document is also recursively sanitized on read, by both the permissions and the MCP adapter: every object is rebuilt from its own entries, so a `__proto__` key cannot swap an object's prototype and `constructor` / `prototype` never survive as own keys. A root `__proto__` is therefore stripped and the rest of the file is read normally, rather than failing the \"must be a JSON object\" check as it did before sanitization was added — the surrounding settings are kept, and only the poisoned key disappears.\n\nAmp shapes with no canonical category are authored (and round-trip) through an optional `amp` override block in `.rulesync/permissions.jsonc`: `permissions` — extra `amp.permissions` entries with non-`cmd` matchers (`path`/`url`/`query`/…), regex/array match values, `context` (`thread`/`subagent`), `delegate` (+`to`), or `reject` (+`message`), appended **after** the canonical-generated entries (so generated allow/ask/reject rules take precedence under first-match-wins, with authored entries as later fallbacks); `mcpPermissions` — Amp's `amp.mcpPermissions` array; `guardedFiles` — `amp.guardedFiles.allowlist` (globs allowed without confirmation); and `dangerouslyAllowAll` — `amp.dangerouslyAllowAll`. When the override authors `permissions` it becomes the source of truth for the extra entries; otherwise any hand-authored `delegate` entry in the existing file is preserved. On import, `amp.permissions` entries that are **not** canonical-expressible (non-`cmd` matcher, `delegate`, `reject`+`message`, `context`) are lifted verbatim into `amp.permissions` of the override rather than dropped. Example: `{ \"permission\": { … }, \"amp\": { \"dangerouslyAllowAll\": false, \"guardedFiles\": { \"allowlist\": [\"docs/**\"] }, \"permissions\": [{ \"tool\": \"Bash\", \"action\": \"delegate\", \"to\": \"approve.sh\" }] } }`. Note that the `Permissions` section of Amp's web docs now only points at custom plugins and no longer describes `amp.permissions`, `amp.guardedFiles.allowlist` or `amp.dangerouslyAllowAll`; the shipped CLI still honors them and still lists all three in its built-in settings reference (`amp --help`), alongside the `amp permissions` subcommands. `amp.mcpPermissions` (like `amp.tools.disable`) remains in the web [CLI settings reference](https://ampcode.com/docs/cli/settings). See the [Amp permissions docs](https://ampcode.com/docs/tools#permissions) and the [plugin permissions example](https://ampcode.com/docs/customize/plugins#example-plugin-permissions).\n\nFor JetBrains Junie CLI, this generates the Action Allowlist `rules` object in `~/.junie/allowlist.json` (**global mode only** — Junie CLI resolves exactly one allowlist path under its home directory and never reads a project-scope `.junie/allowlist.json`; verified against release `2383.10`). Junie evaluates the allowlist top-to-bottom (first match wins) and groups rules into buckets, onto which rulesync categories map: `bash` → `executables`, `edit`/`write` → `fileEditing`, `read` → `readOutsideProject`, `mcp` → `mcpTools`. Every rule group is written as Junie's `AllowListRuleSet` **object** — `{ \"default\"?: \"allow\"|\"ask\", \"rules\": [ … ] }` — never a bare array: Junie's parser rejects the array form for the **whole file** and then discards and overwrites `allowlist.json`, so the shape matters. Earlier rulesync versions emitted the array form; it is still tolerated on import, but only the object form is generated. Each rule carries an `action` plus either a literal `prefix` (matches commands that start with it) or a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`); rulesync emits `pattern` when the canonical pattern contains a glob metacharacter (`*`, `?`, `[`) and `prefix` otherwise. Junie accepts only `allow` and `ask` as actions — there is **no `deny`** (a `deny` fails the whole-file parse) — so a canonical `deny` is downgraded to the nearest valid action, `ask` (which still withholds auto-approval), with a warning (`allow`/`ask` map 1:1). Categories Junie cannot represent (e.g. `webfetch`, `websearch`) are skipped with a warning when they carry rules. rulesync **owns each mapped group's rule list** (replaced on each generate), while a per-group `default` and the whole `readSecretFile` group — which restricts what Junie may read — are preserved from the existing file when not authored via the `junie` override below. Because `edit`/`write` both collapse onto `fileEditing`, importing normalizes back to `edit` (a documented, lossy mapping). The `allowlist.json` file is never deleted. See the [Junie Action Allowlist docs](https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html).\n\n> **Junie-only override (`junie` key):** Junie's `allowlist.json` has settings with no canonical per-glob slot — the top-level autonomy knobs `allowReadonlyCommands` (a boolean auto-allowing read-only commands) and `defaultBehavior` (the fallback action when no rule matches; an `allow`/`ask` enum — Junie's `AllowListDecision` accepts nothing else, and an invalid value fails the whole-file parse), plus two group-shaped settings: `readSecretFile` (the fifth rule group, restricting reads of secret files — canonical `read` is already taken by `readOutsideProject`, so this group is authored whole as `{ \"default\"?, \"rules\": [ … ] }`) and `ruleDefaults` (each mapped group's own fallback action, e.g. `{ \"executables\": \"ask\" }`). Add a tool-scoped `junie` override to author them: the scalar knobs are merged onto the top level of `allowlist.json` (the override wins) while the shared `permission` block keeps driving the mapped groups' rule lists, and the group-shaped settings land inside the `rules` object. On **import**, all of these are lifted from `allowlist.json` into the `junie` override, so they are authorable and portable instead of only round-trip-preserved. Any other unmodeled top-level key is preserved verbatim. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git \": \"allow\" } },\n> \"junie\": {\n> \"allowReadonlyCommands\": true,\n> \"defaultBehavior\": \"ask\",\n> \"ruleDefaults\": { \"executables\": \"ask\" },\n> \"readSecretFile\": { \"rules\": [{ \"pattern\": \"**/.env\", \"action\": \"ask\" }] }\n> }\n> }\n> ```\n\nFor Reasonix, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the `[permissions]` table of the shared `reasonix.toml` (project mode) or `~/.reasonix/config.toml` (global mode) — the same TOML file the MCP feature's `[[plugins]]` array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are `Bash(<pattern>)`, `Read(<pattern>)`, `Edit(<pattern>)`, `Write(<pattern>)`, `WebFetch(<pattern>)`, `WebSearch(<pattern>)`, `Grep(<pattern>)`, `Glob(<pattern>)`, `NotebookEdit(<pattern>)`, `Agent(<pattern>)`, etc. (Reasonix's SPEC.md documents these as \"Claude Code-style\" families; `agent` → `Agent` is the one lower-confidence mapping, since Reasonix's own delegation tool is internally named `task`). `[permissions].mode` (the writer fallback: `ask`/`allow`/`deny`) has no canonical rulesync equivalent: it is preserved untouched unless the `reasonix.mode` override below sets it. Note that Reasonix narrows a project `reasonix.toml` before merging it (`narrowPermissions` in `project_scope.go`, on both the v1 and v2 lines): project `allow`, `mode` and `allow_dynamic_bash` are user-only and are replaced by the user config's values, while project `ask` and `deny` merge additively. A project-mode `allow` entry (and the `allowDynamicBash` override below) therefore only takes effect when the user config (`--global`) carries the same value — or, on v2, when the user has granted it to that workspace in Reasonix's own project-grant store — and Reasonix reports the rest as ignored; see [project_scope.go (v2.31.0)](https://github.com/esengine/DeepSeek-Reasonix/blob/v2.31.0/internal/contract/config/project_scope.go) and [(v1.39.8)](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.39.8/internal/config/project_scope.go). The TOML file is shared with the MCP feature, so writes only replace the `permissions` table — every other table (`[[plugins]]`, `[agent]`, `[ui]`, …) is preserved on round-trip, and the file is never deleted. See [SPEC.md §3.7 Permissions](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md).\n\n> **Reasonix-only override (`reasonix` key):** Reasonix has security axes orthogonal to per-tool allow/ask/deny with no canonical category — the `[sandbox]` enforcement table (`workspace_root`, `allow_write`, `forbid_read`, `bash` = `enforce`/`off`, `network`) and the plan-mode read-only command list under `[agent]` (`plan_mode_read_only_commands`, which upstream keeps for legacy compatibility only — Plan bash goes through Permissions now). Its sibling `plan_mode_allowed_tools` left the documented config surface in v1.17.18: an existing value is still lifted out of `[agent]` on import, so it does not vanish from an imported config, but whenever the override writes `[agent]` the key is removed from the file with a warning — including a value already there, since leaving that one alone would mean narrowing the list is the one edit that never lands. Add a tool-scoped `reasonix` override to author them: `reasonix.sandbox` and `reasonix.agent` are shallow-merged into the matching `reasonix.toml` table at its top level (override keys win, unrelated sibling keys such as `[agent].model` are preserved), while the shared `permission` block keeps driving `[permissions].allow`/`ask`/`deny`. The `[sandbox]` values that loosen the enforcement layer under the permission policy — a `bash` set to anything but `enforce`, a `network` that is not `false`, a non-empty `allow_write`, and a `workspace_root` that is anything but a plain relative path inside the project — an absolute path in POSIX or Windows form, a drive-lettered one such as `C:temp`, a home-relative one, a path holding a `..` segment, or one carrying a `$…` / `%…%` expansion whose target cannot be known here; as with the other keys, a value that is not the type Reasonix documents is reported rather than assumed harmless — are written, but named in the same one-warning-per-file summary rulesync gives Claude Code's and Devin's trust-affecting sandbox paths. Because the merge replaces `forbid_read` whole rather than accumulating it, that summary also names the read protections a generate would drop: a `forbid_read` that loses an entry the file had, one the file holds as something other than a list so what it blocked cannot be read, and a `[sandbox]` the file holds in some shape other than a table, which the write replaces wholesale. A value already in the file that the override does not author is left alone and not reported. The override also carries `rawAllow`/`rawAsk`/`rawDeny` — verbatim `[permissions]` entries merged into the generated arrays untranslated. They exist for the first-class `Bash=<literal>` exact-command form (SPEC §3.7, v1.18.0: metacharacters in the literal are ordinary characters and only the identical complete command matches), which the canonical tool→pattern→action shape cannot express. It is also the pattern-level way to pre-authorize **nested or indirect** Bash — command and process substitution, `eval`, `source`, `sh -c` and the like, which Reasonix gates harder than a merely dynamic command line — in a headless `reasonix run`. The blanket alternative is `reasonix.allowDynamicBash`, which writes `[permissions] allow_dynamic_bash` (added in v1.19.0): where a `rawAllow` entry pre-authorizes one exact literal, this lets an Allow fallback — Auto included — cover that whole class at once, with explicit `ask` and `deny` rules keeping precedence over it. Setting it to `true` is written but named in a warning, as with the other keys that widen what a shareable permissions file lets run without a human; leaving the key out of the override keeps whatever the file already had, and it is lifted back into the override on import when it holds the boolean Reasonix documents. YOLO is a run-time posture rather than config, so there is nothing to author for it. `reasonix.mode` (`ask`, `allow` or `deny`) writes the `[permissions] mode` writer fallback. Reasonix honors it only in the user config and ignores a project value, so it is written only with `--global`; a project-scope generate skips it with a warning and keeps whatever the file had. A global `mode` newly set to `allow` is named in a warning, because it lets every writer call that no rule matches run without a prompt. On import it is lifted back into the override from the user config only, when it holds one of the three documented values. Exact entries already in `reasonix.toml` — Reasonix writes them itself as remembered approvals — are always preserved on generate, even for tools the shared block manages. On import, the whole `[sandbox]` table round-trips (it is a dedicated security surface), only the plan-mode keys are lifted from `[agent]`, and exact `Tool=<literal>` entries are lifted into `rawAllow`/`rawAsk`/`rawDeny` instead of masquerading as a bogus tool category in the shared block.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git status*\": \"allow\" } },\n> \"reasonix\": {\n> \"sandbox\": { \"bash\": \"enforce\", \"network\": false },\n> \"agent\": { \"plan_mode_read_only_commands\": [\"gh pr diff\"] },\n> \"allowDynamicBash\": true\n> }\n> }\n> ```\n>\n> The retired `[[plugins]].trusted_read_only_tools` MCP read-only trust list is per-plugin (an array-of-tables shared with the MCP feature) and is not covered by this override.\n\nFor Devin Local, this generates the `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays of the shared `.devin/config.json` (project mode) or `~/.config/devin/config.json` (global mode) — in global mode the same file the hooks feature patches (MCP moved out to `mcp_config.json` in v3000.3); rulesync writes project-scope Devin hooks to a standalone `.devin/hooks.v1.json` instead, so a project `.devin/config.json` carries permissions alone. Entries are Devin's scope matchers: `read` → `Read(<glob>)`, `write`/`edit` → `Write(<glob>)`, `bash` → `Exec(<prefix>)`, `webfetch` → `Fetch(<pattern>)`, `websearch` → the bare `web_search` tool name (accepted in the permission lists since CLI v3000.10.21), plus `mcp__server__tool` names passed through verbatim. A `*` pattern collapses to the bare scope (`Exec`). `web_search` has no pattern matcher, so pattern-specific `websearch` rules are collapsed to a single action with a warning (`deny` > `ask` > `allow`; a map without `*` also counts an implicit `ask`, so a narrow allowlist never becomes a blanket allow; an empty `websearch` map emits nothing — like any other empty category it still retracts the `web_search` entries rulesync manages, which leaves Devin's auto-approve default in place), and on import `web_search` maps back to `websearch`. A CLI older than v3000.10.21 rejects a `web_search` entry, so upgrade before generating one. Devin evaluates the arrays with `deny` > `ask` > `allow` precedence, so a deny rule always wins; on import `Write` maps back to `write`, so `edit` rules round-trip as `write`. Only the keys rulesync manages are rewritten — `permissions`, plus `sandbox` in global mode when the `devin` override below authors it — and every other key is preserved and the file is never deleted. See the [Devin permissions reference](https://docs.devin.ai/cli/reference/permissions).\n\nFor Continue, permissions are **global-only** and generate the `allow`, `ask` and `exclude` lists of `~/.continue/permissions.yaml`, the tool-policy file of the Continue CLI (`cn`) — Continue reads no project-scoped policy file, so a project-scope run is skipped with a warning and `--global` is required. Each entry is `Tool` or `Tool(pattern)`: the canonical categories map to Continue's built-in tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `webfetch` → `Fetch`), the `*` pattern of a category becomes the bare tool name, the all-tools `*` category becomes a bare `*` (a pattern-specific rule under `*` is skipped with a warning, because Continue would match it against no tool argument; when a `bash` category exists it is mirrored onto `Bash` instead), and any other category is passed through as a tool name (which is how an MCP tool is named). `deny` lands in `exclude`; a pattern that itself contains a parenthesis is skipped with a warning because Continue's parser rejects it, and a rule that appears in more than one list is named in a warning. Entries already in the file that name a tool the canonical block does not mention are kept, and entries Rulesync cannot parse are left where they are; an empty canonical block writes no file. On import the three lists map back to `allow` / `ask` / `deny` (first list wins for a duplicated entry). See the [tool permissions docs](https://docs.continue.dev/cli/tool-permissions).\n\nFor CodeBuddy Code, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.codebuddy/settings.json` (project mode) or `~/.codebuddy/settings.json` (global mode), in Claude Code's rule syntax: `Tool` or `Tool(specifier)`. The canonical categories map to CodeBuddy's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `notebookedit` → `NotebookEdit`, `webfetch` → `WebFetch`, `websearch` → `WebSearch`, `agent` → `Agent`, `skill` → `Skill`), the `*` pattern of a category becomes the bare tool name, and patterns are written as they are, so CodeBuddy's own spellings (`Bash(npm run test:*)`, `Read(//abs/path)`, `WebFetch(domain:example.com)`) work unchanged. Any other category is written as the tool name it is (`mcp__github`, `mcp__github__get_issue`, `TaskCreate`), like the `claudecode` target; an `mcp__` category takes no specifier, so a pattern other than `*` there is skipped with a warning. The bare `mcp` category becomes `mcp__<pattern>` and its `*` pattern the all-MCP wildcard `mcp__*`, which CodeBuddy honors in `deny` and `ask` only, so an `mcp: { \"*\": \"allow\" }` is skipped with a warning. The all-tools `*` category's `*` pattern becomes CodeBuddy's standalone `*` rule (which does not cover MCP tools); a narrower `*` pattern is mirrored onto `Bash` when a `bash` category exists and is otherwise skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins, matching CodeBuddy's `deny` > `ask` > `allow` precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: `defaultMode`, `additionalDirectories`, `trustedDirectories` and the other keys of `permissions` are kept, as is every existing entry for a tool the canonical config does not mention; a list that ends up empty is removed, and the file itself is never deleted. The gitignored `.codebuddy/settings.local.json` layer is left to CodeBuddy. On import each entry maps back to its category and pattern (`mcp__*` to the bare `mcp` category, the standalone `*` to the all-tools `*` category, an empty `Tool()` or unbalanced entry is skipped), with the strictest action winning for an entry listed more than once. See the [permissions docs](https://www.codebuddy.ai/docs/cli/permissions).\n\nFor Command Code, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.commandcode/settings.json` (project mode) or `~/.commandcode/settings.json` (global mode). Each entry is `Tool` or `Tool(pattern)`: the canonical categories map to Command Code's friendly tool names (`bash` → `Shell`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `webfetch` → `WebFetch`, `websearch` → `WebSearch` — the last two are resolved by Command Code's rule parser even though the docs only list the first six, and `Edit` covers Command Code's `write_file` tool as well as `edit_file`, so an `edit` allow also lets new files be created unless a `write` rule says otherwise, just as `Read` covers `grep` and `glob` (and `read_directory` / `read_multiple_files`) besides `read_file`, so a `read` rule governs them too — in both cases Command Code's `deny` > `ask` > `allow` precedence decides when the rules disagree; `notebookedit` and `agent` are skipped even though the parser resolves `NotebookEdit` and `Agent` too, because Command Code has no notebook tool — `NotebookEdit` is the same `edit_file` / `write_file` set as `Edit`, so a `notebookedit` rule would govern every file edit — and its permission check answers `allow` for the agent tool before it consults any rule, so an `Agent` deny or ask is never enforced), the `*` pattern of a category becomes the bare tool name, `mcp__<server>__<tool>` categories pass through verbatim (Command Code matches MCP server and tool names case-sensitively, so the category keeps its spelling) and the bare `mcp` category becomes `mcp__*` (Command Code ignores the `(specifier)` of an `mcp__` rule in every list and applies the rule to the whole tool, so a pattern-specific MCP `deny`/`ask` is written as the bare tool name with a warning, and a pattern-specific MCP `allow` — which would grant the whole tool — is not written, with a warning), and the all-tools `*` category becomes a bare `*` in `deny`/`ask` (Command Code ignores a tool-name wildcard such as `*` or `mcp__*` in `allow`, so it is not written there and a warning is logged; a pattern-specific rule under `*` is mirrored onto `Shell` when a `bash` category exists). A category with no rule name upstream is skipped, with a warning when it carries a `deny`. Note that a path-scoped `Read(pattern)`, `Grep(pattern)` or `Glob(pattern)` rule only matches a call that carries a `file_path` / `path` / `target_directory` input (Command Code's own semantics, as with Claude Code), so a `grep: { \"**/.env\": \"deny\" }` does not stop a `grep` over the working directory that names no path — use the bare `grep` / `glob` deny (the `*` pattern) or a `bash` deny when you need a guard. When two canonical rules collapse onto one entry the strictest action wins, matching Command Code's own `deny` > `ask` > `allow` precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: `defaultMode`, `additionalDirectories`, `disableBypass` and the other keys of the file are kept, and every existing entry for a tool the canonical config does not mention — an internal name such as `edit_file`, a name wildcard such as `edit_*`, or a modeled tool without a canonical category — is preserved verbatim, so the approvals Command Code writes into the same lists survive a regenerate. An entry for a rule name this run emits (case-insensitively for the friendly tool names, exactly for MCP names — `mcp__<server>` from the bare `mcp` category included, and `mcp__<server>__*` / `mcp__<server>__` are that same whole server) is replaced in whichever list it sits, so flipping a rule from `deny` to `allow` does not leave the old `deny` behind; a `mcp.<server>` rule you remove from the canonical config, however, leaves its `mcp__<server>` entry in place like any other unmentioned tool — delete it by hand. A `deny` or `ask` entry that is reclaimed this way without an equally strict rule — the same one, or the category-wide `*` rule that covers it — taking its place (a hand-written `mcp__*` deny next to a canonical `mcp: { github: \"deny\" }`, say) is reported with a warning, so a regenerate never loosens the file silently. A canonical block that names no category leaves every existing entry in place; a list that ends up empty after the rewrite is removed rather than written as `[]`, and the file itself is never deleted. On import the lists map back to `allow` / `ask` / `deny` as Command Code enforces each entry (the friendly tool names fold case, and the internal aliases Command Code's rule parser accepts for the same tools — `Bash`, `PowerShell`, `shell_command`, `monitor_command` and `kill_shell` for `bash`, `NotebookEdit` for `edit`, `write_file` for `write`, `web_fetch` / `web_search` for `webfetch` / `websearch` — fold onto their category, while `Agent` / `Task` enforce nothing and are skipped; `Tool`, `Tool()` and `Tool(*)` all mean the whole tool; the rule is split at its first unescaped `(` with the tool half trimmed and `\\(` / `\\)` in the specifier unescaped, as Command Code's own parser does, and a rule that opens a parenthesis without closing one is no rule at all and is skipped; a `Shell` specifier has its whitespace trimmed and collapsed the way Command Code's shell matcher does (`Shell( )` matches nothing and is skipped; a padded `Shell( * )` is a pattern rule, not the bare `Shell` — only a specifier of exactly `*` means the whole tool — so it imports as the whole `bash` category from `deny`/`ask`, where it matches every command, and is skipped with a warning from `allow`, where it is narrower than the bare `Shell`), whereas every other specifier is taken as written, so a padded `Read( * )` matches nothing and is skipped; a bare `:*` shell specifier (`Shell(:*)`), whose prefix is empty, matches nothing in Command Code either and is skipped rather than imported as an empty-prefix grant; an `mcp__<server>__<tool>(specifier)` imports as the whole tool from every list — which is what Command Code enforces for it, the specifier being ignored — with a warning from `allow`, where that widens the grant as written; MCP names keep their case; a `mcp__<server>`, `mcp__<server>__*` or `mcp__<server>__` entry becomes its own `mcp__<server>` category rather than a pattern of the bare `mcp` one, and a `*` server (`mcp__*`, `mcp__*__<tool>`) is every MCP tool, the bare `mcp` category; and a differently-cased `MCP__...` spelling, which Command Code reads as a plain tool-name rule because it only recognizes the MCP shape from an exact `mcp__` prefix, imports as what that rule does there: `MCP__<server>__<tool>` folds to the `mcp__` category spelled as written, with a warning that the exact-prefix rule generated from it matches the names exactly where the name rule matched them case-insensitively, `MCP__<server>__<tool>(specifier)` — whose specifier Command Code globs against the call's `command` / `file_path` / `path` / `url` / `pattern` argument — keeps its specifier as the pattern in `deny`/`ask` but is skipped in `allow` with a warning, where such a grant cannot be imported without widening it, the name globs `MCP__*` (every MCP tool, the bare `mcp` category) and `MCP__<server>__*` (the whole server, `mcp__<server>`) import from `deny`/`ask` only, and `MCP__<server>` names no tool and is left alone), with the strictest action winning for a tool named more than once, unmodeled entries skipped (with a warning when they sit in `deny` or `ask`, since an internal-name rule such as `edit_file(**/.env)` is enforced by Command Code even though rulesync cannot carry it to other targets; it stays in the settings file, where a regenerate keeps it), a tool-half glob in the exact-prefix spelling (`mcp__<server>__get_*`), which Command Code matches against tool names in every list, imported as the category spelled that way — the one the generator writes back — which other targets read as a literal tool name, a list entry that is not a string skipped on its own with a warning, as Command Code skips it (on regenerate such an entry is dropped from the rewritten list, also with a warning), and a server-less wildcard (`*`, `*()`, `mcp__*`, `mcp__*()`) in `allow` skipped with a warning because Command Code ignores it there. See the [permissions docs](https://commandcode.ai/docs/permissions).\n\nFor Letta Code, this generates the `permissions.allow`, `permissions.alwaysAsk` and `permissions.deny` arrays of the shared `.letta/settings.json` (project mode) or `~/.letta/settings.json` (global mode). Letta Code checks `deny`, then `alwaysAsk`, then its permission mode, and only then `allow` and its plain `ask` list, so a canonical `ask` is written to `alwaysAsk`: a plain `ask` entry would lose to a broader `allow` and to an auto-approving mode. An existing plain `ask` entry for a managed tool is therefore imported as `ask` and rewritten into `alwaysAsk` on the next generate. The categories map to Letta Code's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `glob` → `Glob`, `grep` → `Grep`); the `*` pattern of a category becomes the bare tool name. Other categories (`webfetch`, `websearch`, `mcp`, ...) have no rule in Letta Code and are skipped with a warning; a rule under the all-tools `*` category is applied to `Bash`. File-tool patterns are passed through as Letta Code globs. Letta Code's `ApplyPatch` tool takes no path rules, so `edit` and `write` path rules do not restrict it. Letta Code matches a `Bash` rule exactly, or as a prefix when it ends in `:*`, so a canonical pattern whose only `*` is trailing becomes a prefix rule — `git *` becomes `Bash(git:*)`, which also matches `gitk` since Letta Code trims the space before `:*` — and a pattern without `*` stays an exact match. A pattern with a `*` in the middle cannot be expressed: an `allow` is dropped with a warning, and a `deny` or `ask` is widened to the prefix before the first `*` with a warning, so it never loosens. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: `mode`, `additionalDirectories` and the other keys of `permissions`, and entries for other tools (`Task`, `ListDir`, ...), are kept, and a list that ends up empty is removed. On import `allow`, `ask`/`alwaysAsk` and `deny` map back to `allow`, `ask` and `deny` (Letta Code's aliases such as `shell` and `read_file` fold onto their category, `X:*` becomes `X *`), with the strictest action winning for a rule listed more than once. See the [permissions docs](https://docs.letta.com/configuration/permissions/index.md).\n\nFor Qoder, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.qoder/settings.json` (project mode) or `~/.qoder/settings.json` (global mode); the per-machine `.qoder/settings.local.json`, where Qoder's `/allow` and `/deny` commands persist, is left to you. Qoder checks `deny`, then `ask`, then `allow`. The categories map to Qoder's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `webfetch` → `WebFetch`, `websearch` → `WebSearch`, `agent` → `Agent`); the `*` pattern of a category becomes the bare tool name and any other pattern is written verbatim as `Tool(pattern)` — Qoder matches `Bash` payloads exactly, as a `:*` prefix or as a glob, file payloads as gitignore-style paths (`/src/**` is rooted at the project, or at the home directory in the user file), and `Agent` payloads as subagent names. Qoder checks path-scoped writes against `Edit(...)` rules and path-scoped reads against `Read(...)` rules, so a `write` rule with a path is written as `Edit(path)` and, following Claude Code (whose permission model Qoder mirrors; Qoder does not document `Glob` payloads), a `glob` rule with a path as `Read(path)`; such an entry is managed as an `Edit` / `Read` entry, so it is only rewritten on a later generate when the canonical config also names `edit` / `read`, and it imports back under `edit` / `read`. An `mcp__...` category (`mcp__github__create_issue`, `mcp__github__*`, `mcp__*`) is written as the bare MCP tool name; a narrower pattern under it is skipped with a warning. The all-tools `*` category's `*` pattern becomes Qoder's bare `*` rule; its other patterns are applied to `Bash` and otherwise skipped with a warning. Other categories (`notebookedit`, ...) are skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: `additionalDirectories`, `trustDirectories` and the other keys of `permissions`, and entries for other tools, are kept, and a list that ends up empty is removed. On import each entry maps back to its category and pattern verbatim (`Bash(git log:*)` stays `git log:*`), with the strictest action winning for a rule listed more than once. See the [permissions docs](https://docs.qoder.com/en/cli/permissions).\n\n> **Crush-only override (`crush` key):** Crush has no per-path or per-command permission model — its config carries two tool-wide lists, `permissions.allowed_tools` (tools that skip the confirmation prompt, as `tool` or `tool:action` entries) and `options.disabled_tools` (built-in tools removed from the agent altogether), written into the shared `crush.json` (project; `.crush.json` when that file already exists) / `~/.config/crush/crush.json` (global, via `--global`) by deep merge, so the sibling keys (`providers`, `mcp`, `hooks`, ...) survive and the file is never deleted. At project scope Crush merges `.crush.json` and `crush.json` (lists concatenated, `.crush.json` on top), so `allowed_tools`/`disabled_tools` entries left in the `crush.json` next to a `.crush.json` stay in effect and are named in a warning rather than retracted; import reads the merged pair. Each canonical category maps to a whole built-in tool name (`bash` → `bash`, `read` → `view`, `edit` → `edit`, `write` → `write`, `grep` → `grep`, `glob` → `glob`, `webfetch` → `fetch`; a `mcp__<server>__<tool>` category becomes Crush's `mcp_<server>_<tool>` name, and any other category is passed through as a tool name), so only the catch-all `*` pattern is written and narrower patterns (`git *`, `src/**`) are skipped with a warning — Crush prompts per call and cannot be told which arguments to approve. A catch-all `allow` lands in `permissions.allowed_tools`; a catch-all `deny` lands in `options.disabled_tools`, which only covers Crush's built-in tools, so a `deny` on an MCP tool is skipped with a warning (disable it through the server's `disabled_tools` in `.rulesync/mcp.jsonc` instead); `ask` writes nothing — a tool in neither list keeps Crush's default prompt. Because an `allowed_tools` entry approves every invocation of that tool, a catch-all `allow` is withheld (and named in a warning) when the same category also carries a narrower `deny` or `ask` — with `bash: { \"*\": \"allow\", \"rm -rf *\": \"deny\" }` nothing is written for `bash`, so Crush keeps prompting rather than auto-approving the denied commands. The all-tools `*` category has no Crush counterpart (there is no allow-all list, and emptying `disabled_tools` is not a disable-all) and is skipped with a warning. Entries in either list that rulesync did not derive — `tool:action` forms, MCP names, tools with no canonical category — are preserved on regenerate, and a rule that flips to `ask` (or a category whose catch-all is dropped while the category itself stays declared) retracts the entry it had written; removing a category wholesale leaves the entry rulesync can no longer tell apart from a hand-written one, so set it to `ask` first to retract it. Author the rest of the `permissions` block (`skip_requests`) and the other `options` keys directly in `crush.json`; they are untouched. On import, every plain tool entry in `permissions.allowed_tools` becomes `{ \"*\": \"allow\" }` for its category and every `options.disabled_tools` entry `{ \"*\": \"deny\" }` (deny wins when a tool is in both); `tool:action` entries are narrower than a tool-wide rule and are not imported. See the [permissions docs](https://github.com/charmbracelet/crush/blob/main/docs/config/README.md#permissions).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) keeps its permission rules in the same YAML settings file as its MCP servers — `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) and `~/.config/poolside/settings.yaml` (global, via `--global`) — under two top-level keys, written by deep merge so the sibling keys (`pool`, `mcp_servers`, `sandbox`, `hooks`, ...) survive and the file is never deleted. Tool categories become `tools.<name>` blocks with `allow` and `deny` pattern lists matched against the tool's argument: `bash` → `tools.shell`, `webfetch` → `tools.web_fetch`, `websearch` → `tools.web_search`, and any other non-file category is passed through as `tools.<category>`. An `allow` rule lands in `allow`, a `deny` rule in `deny` (Pool lets a denied pattern win over an allowed one, so nothing is withheld for it), and `ask` writes nothing — a call that matches neither list prompts, which is Pool's default — except that an `allow` overlapping an `ask` of any category that lands on the same Pool tool (`bash` and a pass-through `shell` both write `tools.shell`, which Pool matches as one list) is withheld and named in a warning, since an allowed pattern would silence the prompt the config asks for. Pool's tool patterns know one wildcard, `*`, which matches anything including `/` (so a `**` run is written as `*`); a pattern that relies on `?`, `[...]` or `{a,b}` cannot be expressed and is skipped with a warning — and when it is a `deny`, it withholds the allows it overlaps on the same Pool tool just as an `ask` does, so the skipped deny is honoured by prompting rather than silently auto-approved. The file categories `read`, `edit` and `write` become `paths` entries instead, Pool's file-access model: a `read` allow is a read-only entry (`{ path }`), an `edit` or `write` allow adds `write: true` (Pool has a single write permission covering edits, creations, deletions and renames), and a `read` deny is a `paths.deny` entry, which wins over any allow; a bare `*` is written as `**` because a `*` in Pool's path globs is one segment, and since those globs know only `*` and `**`, a path pattern that relies on `?`, `[...]` or `{a,b}` is skipped with a warning — a skipped deny withholding the allows it overlaps, as on the tool side, rather than being written as a literal Pool would never match. Pool cannot deny writes without denying reads, so an `edit`/`write` deny that is not also a `read` deny is not written (a warning says so) and only withholds the write flag of the allows it overlaps — with `read: { \"src/**\": \"allow\" }, edit: { \"src/**\": \"allow\", \"src/generated/**\": \"deny\" }` the entry is written read-only and Pool prompts for the writes; an `ask` in a file category likewise withholds the overlapping allows (a write entry also grants reading, so a `read` ask withholds it too). Project settings take project-relative paths and the user settings absolute or `~` paths, so a path of the other kind is skipped with a warning at each scope (a `read` deny skipped that way exempts no write deny, since nothing lands in `paths.deny` for it). The all-tools `*` category has no Pool counterpart: its restrictions are mirrored onto `bash` and the category itself is skipped with a warning. A `mcp__<server>__<tool>` category is skipped too — Pool approves MCP tools per server under `mcp_servers.<server>.allow`, which the MCP feature writes from the server's `poolAllow` in `.rulesync/mcp.jsonc`. On regenerate the `allow`/`deny` lists of the tools the canonical config names are rebuilt (an empty list retracts its key) and each tool's `disabled` flag and every unnamed tool are left alone; removing a category wholesale leaves the lists rulesync can no longer tell apart from hand-written ones, so set its rules to `ask` first to retract them. The `paths` lists are rebuilt whenever the canonical config carries a file category and left untouched otherwise. On import, each `tools.<name>` block maps back to its category (`shell` → `bash`, `web_fetch` → `webfetch`, `web_search` → `websearch`, anything else verbatim) with its `allow`/`deny` patterns (deny wins when a pattern is in both) and `disabled: true` as `{ \"*\": \"deny\" }`; a `paths.allow` entry becomes a `read` allow (plus `edit` and `write` allows when it carries `write: true`) and a `paths.deny` entry a deny in all three; `mcp_servers` is not read. See the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **Tabnine CLI-only override (`tabnine` key):** Tabnine CLI has no per-path or per-URL permission model — its `settings.json` carries two tool lists, `tools.allowed` (tools that skip the confirmation prompt) and `tools.exclude` (tools removed from discovery), written into the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global, via `--global`) by deep merge, so the sibling keys (`mcpServers`, `hooks`, `general`, `context`, ...) survive and the file is never deleted. From the canonical block, a `bash` rule whose pattern is a command prefix (`git *`, `pnpm`, or the bare `*` for the whole tool) becomes `run_shell_command(<prefix>)` — Tabnine matches the prefix against the start of the command line (so a bare `pnpm` also covers `pnpm install`), and any other shell pattern (`git * push`, `*.sh`) is skipped with a warning. A `bash` `deny` is written as `run_shell_command(<prefix>)` in `tools.exclude` — the form Gemini CLI, which Tabnine CLI derives from, honors in `excludeTools` — but Tabnine documents the prefix syntax for `tools.allowed` only, so that entry is not relied on to enforce the rule. Because a written prefix approves every longer command line too, each `bash` `allow` is compared at that width against every rule that restricts shell commands — a `bash` `deny` (written as a prefix or not), a `bash` `ask`, and the `deny` and `ask` rules of the all-tools `*` category — and is withheld when it overlaps one, rather than auto-approving the restricted commands: with `git *` allowed and `git push *` denied, a bare `pnpm` allowed and `pnpm publish *` asked about, or `git *` allowed and `git * --force` denied, the allow is not written, and each withheld rule is named in a warning. Every other category maps to a whole built-in tool name (`read` → `read_file`, `edit` → `replace`, `write` → `write_file`, `webfetch` → `web_fetch`, `websearch` → `google_web_search`, `grep` → `grep_search`, `glob` → `glob`; an unknown category is passed through as a tool name, which is how an MCP tool is named), so only the `*` pattern is written and narrower patterns are skipped with a warning. `allow` lands in `tools.allowed`, `deny` in `tools.exclude`, and `ask` writes nothing — a tool in neither list keeps Tabnine's default prompt. The rest of the `tools` group (`core`, `shell.*`, `enableWebTools`, ...) and the `general` group are authored through the `tabnine` override, which is deep-merged beneath the two canonical lists — only those two groups are written through the permissions feature; any other key of the override (`mcpServers`, `hooks`, ...) is ignored with a warning, since the adapter that owns it writes it. Six keys of those groups are refused outright with a warning rather than written — `tools.discoveryCommand`, `tools.callCommand`, `tools.shell.pager` and `tools.sandbox.command`, whose values Tabnine CLI spawns as commands, `general.tabnineHost`, the host every login and API call (and the credentials with them) goes to, and `general.preferredEditor`, an editor executable path — so a permissions file (one fetched with `rulesync fetch` included) cannot point it at an executable or a server of its choosing; set them by hand in `settings.json` if you need them. A `run_shell_command(<prefix>)` entry in the override's own `tools.allowed` is a `bash` allow spelled the Tabnine way, so it joins the canonical allows and is withheld by the same deny/ask comparison rather than written past it; a prefix spelled as a glob (`run_shell_command(git *)`, `run_shell_command(*)`) is read as that glob — Tabnine would match it literally — and written back as the prefix it denotes (`run_shell_command(git)`, `run_shell_command`) with a warning; import reads such an entry in `settings.json` back as the same glob. The override settings that widen what Tabnine does unattended are written as authored but named in a single trust-affecting warning: the override's other `tools.allowed` entries (a non-shell entry is only checked against a whole-tool deny of its tool: one naming a tool the canonical block excludes is withheld with a warning, since Tabnine never loads an excluded tool), a `tools.sandbox` that is not the plain `true`, and a `general.defaultApprovalMode` other than `default` or `plan`. Entries already in `tools.allowed`/`tools.exclude` that name a tool the canonical block does not mention (an MCP tool, a hand-written `run_shell_command(...)` prefix while the block has neither a `bash` nor a `*` rule) are kept; the entries of a tool the block does name are rewritten from it (an existing `tools.exclude` entry that goes this way is named in a warning, since removing it loosens the policy; a dropped `tools.allowed` entry only brings back the confirmation prompt and is named at info level), and the `*` category counts as naming the shell tool since its rules restrict shell commands. On import, list entries that cannot be mapped back (a prefix on a non-shell tool, a malformed entry, a tool whose name cannot be a permission category such as `__proto__`) round-trip through that override too; the import leaves those six refused keys in `settings.json` rather than lifting them (a generate would only refuse them) and announces the trust-affecting keys it does lift, so the resulting `.rulesync/permissions.jsonc` is read with the same eyes as one that arrived with a clone. See the [built-in tools docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/built-in-tools) and the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference).\n\n> **Devin-only override (`devin` key):** the sibling top-level `sandbox` block decides what a permitted command may _reach_ rather than which commands are permitted, so it has no canonical category: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating allow), `network_mode` (`full`, the upstream default, allows every HTTP method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` / `deny` lists of `Exec(...)` matchers deciding which commands run _outside_ the sandbox: `allow` runs them outside it, `ask` asks first, and `deny` pins them inside it). Author it through a tool-scoped `devin` override; it is shallow-merged into the `sandbox` object at its top level, so the override's keys win while sibling keys you set directly are preserved, and the whole block round-trips back into the override on import. Devin documents `sandbox` as a **User Config Only** key, so it is written in global mode only — at project scope it is dropped with a warning rather than written into a file Devin would ignore. The values that loosen the sandbox — a `network_mode` set to anything but `limited`, and the `excluded.allow` / `excluded.ask` lists that take a command out of the sandbox entirely — are still written, but never silently: rulesync names them in one warning per file, as it does for Claude Code's trust-affecting `sandbox` paths, because a permissions file is shareable and `rulesync fetch` can copy one into a project. `allowed_domains` runs the other way — while it is non-empty it is an allowlist, so it is _adding_ to it, or emptying it back to \"every domain\", that widens — and because Devin replaces these lists rather than accumulating them, the same warning also names the restrictions a generate would drop: a `denied_domains` or `excluded.deny` that loses an entry the file had (a swap counts, not just a shorter list), an `excluded.deny` that disappears because the override states some other `excluded` key, or an `allowed_domains` that gains a domain or is emptied. Paths the override leaves alone are not reported, since the merge keeps them exactly as the file had them. The shallow merge only ever writes keys, so removing one from the override does not remove it from `config.json` — the value an earlier generate wrote stays until you delete it there by hand. The block only takes effect when Devin runs with `--sandbox` — either passed on the command line or forced on by an organization that sets sandbox enforcement to Required.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git status *\": \"allow\" } },\n> \"devin\": {\n> \"sandbox\": {\n> \"allowed_domains\": [\"github.com\", \"**.npmjs.org\"],\n> \"network_mode\": \"limited\",\n> \"excluded\": { \"allow\": [\"Exec(git status *)\"], \"deny\": [\"Exec(git tag *)\"] }\n> }\n> }\n> }\n> ```\n>\n> See the [Devin sandbox docs](https://docs.devin.ai/cli/sandbox) and the [config-file reference](https://docs.devin.ai/cli/reference/configuration/config-file).\n\n> **Note: Interaction with deprecated ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. Migrate the ignore patterns to `read` deny rules in `.rulesync/permissions.jsonc`, then remove `ignore` from the project features and delete the obsolete ignore source.\n",
|
|
4343
|
+
"reference/file-formats": "# File Formats\n\n## Symlinks\n\nRulesync follows symbolic links when it discovers source files, whether you use a plain `.rulesync/` directory or separate `--input-roots`. Glob-based discovery (rules, commands, subagents, skills) follows symlinked files and directories; single fixed-path files such as `.rulesyncignore`, `.rulesync/mcp.jsonc`, and `.rulesync/permissions.jsonc` are likewise resolved transparently by the OS when read. A symlink inside the input tree that points elsewhere is followed transparently, and the resolved file content is copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks without duplication (see [issue #1707](https://github.com/dyoshikawa/rulesync/issues/1707)). A directory link that leads back to a directory the walk is already inside (for example `loop -> .` or `up -> ..` reaching the source tree again) is not descended a second time, so a link loop ends the walk instead of repeating it until the OS reports `ELOOP`; the files below that point are still found once, under the directory the walk is already inside.\n\nThe trust boundary is the directory you point Rulesync at. There is **no** `realpath`-based containment check on individual symlinks, so a link may resolve to a target outside the input root — enforcing containment would break the shared-file use case above. Only run Rulesync against trees you control. Directory symlink **cycles** are handled safely: a glob walk does not descend a link back into a directory it is already inside, and glob-based results are deduplicated by the real file they resolve to, so a cycle does not produce duplicated output, and two names for one file (a `docs/reference.md` link pointing at a top-level `reference.md`, say) yield a single entry. Skill directories are not discovered by globs at all — they are walked directly, and that walk keeps every entry it reaches, so a supporting file remains available under each name the skill gives it. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that never **follows** a symlink. A symlink in the remote repository is skipped rather than downloaded, so untrusted remote content never has its links resolved. Locally, the stale-file prune described in [CLI Commands](./cli-commands.md#pruning-fetched-skill-directories) removes a stale link itself without reading through it, and refuses to prune a skill directory that is a symlink at all, so a shared skill you linked in stays untouched.\n\nThree exceptions narrow this within skill directories. First, the entries a skill directory never carries (listed in the supporting-file note below) are excluded by the name they really resolve to, not just the name they have inside the skill — a link called `vendor` pointing at `~/.aws` is refused exactly as a directory called `.aws` is. Second, a link whose real path lands in a system pseudo-filesystem (`/proc`, `/sys`, `/dev`) is refused: `/proc/self/environ` looks like an ordinary file but reads back the environment of the running process. Third, a skill directory's companion files include hidden (dot-prefixed) entries, so an ordinary-looking link to a home directory would otherwise pull in every dotfile beneath it: a hidden entry whose real path resolves outside the skill directory is **not** carried, and the skipped paths are named in a warning. Copy such a file into the directory if the skill really needs it.\n\nWhat decides that third rule is the name inside the skill directory, not the path the link resolves through: a named file keeps the behavior above even when its target sits under a dot-directory such as `~/.dotfiles/skills/`, because somebody chose that name. Reaching outside the directory is reported either way — carried or not — since the content is about to be copied into every enabled tool root.\n\nOn the output side, generation does not write through a link that leads out of the output root. A checked-out repository can carry a symbolic link at an output path — an `AGENTS.md` pointing at `~/.bashrc`, or a tool directory pointing at `/etc` — and following it would land the generated content wherever the link points. Such a file or directory is skipped with a warning naming the path — a skill directory as a whole when any one file in it (its `SKILL.md` or a supporting file) is such a link — and a dangling link is judged by the target it would create, resolved link by link the way the OS would resolve it. A link that stays inside the output root is written through as before, so a dotfiles checkout linked from inside the home directory keeps working with `--global`. Under `--delete`, a skills or subagents directory that is itself such a link (for example `~/.cursor/skills` or `~/.claude/agents`) is written through but not swept: nothing under it is deleted, including stale files inside the skill directories this run writes, and the run warns that its orphan sweep was skipped and still succeeds, since sweeping through the link could delete files rulesync does not manage. Also under `--delete`, two tools whose output directories are one directory through such a link, such as `.cursor/commands` linked to `.claude/commands`, keep each other's files: a file only one of them generates is not swept as the other's orphan.\n\nOne discovery pass is deliberately excluded from the follow-symlinks rule: the scan for nested `AGENTS.md` files (see the `agentsmd` note below). Unlike every other glob above, it walks the whole project rather than a rulesync-owned directory, so a symlink committed to a repository you cloned could otherwise pull a file from outside the project into version-controlled `.rulesync/`. That scan does not follow symlinks.\n\n## `rulesync/rules/*.md`\n\nExample:\n\n```md\n---\nroot: true # true for root-level rules, false for details such as `.agents/memories/*.md`\nlocalRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI), Roo Code, Zoo Code and Devin: AGENTS.local.md; CodeBuddy Code: CODEBUDDY.local.md; Crush: CRUSH.local.md; Reasonix: REASONIX.local.md; Qwen Code: .qwen/QWEN.local.md; Others: append to root file. See the localRoot note below for import behavior\ntargets: [\"*\"] # * = all, or specific tools\ndescription: \"Rulesync project overview and development guidelines for unified AI rules management CLI tool\"\nglobs: [\"**/*\"] # file patterns to match (e.g., [\"*.md\", \"*.txt\"])\nagentsmd: # agentsmd and other nested-AGENTS.md readers (codexcli, warp, ...) specific parameters\n # Support for using nested AGENTS.md files for subprojects in a large monorepo.\n # This option is available only if root is false.\n # If subprojectPath is provided, the file is located in `${subprojectPath}/AGENTS.md`.\n # If subprojectPath is not provided and root is false, the file is located in `.agents/memories/*.md`.\n # Set it to \"auto\" to derive the directory from `globs` (`[\"packages/api/**/*\"]` -> `packages/api`)\n # for this rule alone; the `deriveSubprojectPathFromGlobs` config option does the same for every rule.\n # Set it to \"\" to keep this rule at its default placement even when that option is on.\n subprojectPath: \"path/to/subproject\"\ncursor: # cursor specific parameters\n alwaysApply: true\n description: \"Rulesync project overview and development guidelines for unified AI rules management CLI tool\"\n # Omit this key to fall back to the canonical `globs` above; set it to an\n # explicit `[]`, as here, to write the `.mdc` with no `globs` line at all.\n # `[]` is the right value beside `alwaysApply: true`: the flag already applies\n # the rule everywhere, and Cursor treats a glob next to it as a conflict. On a\n # rule that is *not* Always Apply, put the Cursor-specific patterns here\n # instead (e.g. `[\"src/**/*.ts\"]`). See the Cursor note below.\n globs: []\ncopilot: # copilot specific parameters (non-root `*.instructions.md` files only)\n name: \"TypeScript Style\" # (optional) display name shown in the VS Code UI; defaults to the file name\n excludeAgent: \"code-review\" # (optional) \"code-review\" or \"cloud-agent\": skip this file for that agent\n # Any other frontmatter key found in a hand-written `*.instructions.md` is imported into this\n # section and written back out, so a field Rulesync does not model is not lost on regeneration.\n # `description` and `applyTo` are the exception: they have canonical homes (`description` and\n # `globs`), so a value written for them in this section is overwritten by the canonical one.\nantigravity: # antigravity specific parameters\n trigger: \"always_on\" # always_on, glob, manual, or model_decision\n globs: [\"**/*\"] # (optional) file patterns to match when trigger is \"glob\"\n description: \"When to apply this rule\" # (optional) used with \"model_decision\" trigger\ndevin: # devin (Devin Desktop, formerly Windsurf) specific parameters\n trigger: \"always_on\" # always_on, glob, manual, or model_decision\n globs: [\"**/*\"] # (optional) file patterns to match when trigger is \"glob\"\n description: \"When to apply this rule\" # (optional) used with \"model_decision\" trigger\naiassistant: # aiassistant (JetBrains AI Assistant) specific parameters\n # (optional) Rule type written to the `apply:` line of `.aiassistant/rules/*.md`:\n # always, manually, by model decision, by file patterns, or off.\n # Omit it to derive the type from `globs` and `description`; see the AI Assistant note below.\n apply: \"always\"\ntrae: # trae specific parameters\n # (optional) Overrides the `alwaysApply` value derived from `globs` and `description`,\n # e.g. `false` on a rule with neither makes it apply only via `#Rule`. See the Trae note below.\n alwaysApply: false\n scene: \"git_message\" # (optional) Trae's `scene` field: also apply this rule to AI-generated Git commit messages\naugmentcode: # augmentcode specific parameters\n type: \"always_apply\" # always_apply, manual, or agent_requested\n description: \"When to apply this rule\" # (optional) used with \"agent_requested\" type\nkiro: # kiro specific parameters (steering inclusion)\n inclusion: \"fileMatch\" # always, fileMatch, manual, or auto\n fileMatchPattern: [\"src/components/**/*.tsx\"] # (optional) glob string or array of globs, used when inclusion is \"fileMatch\"\n name: \"api-design\" # (optional) required when inclusion is \"auto\"; the steering entry key\n description: \"REST API design patterns. Use when creating or modifying API endpoints.\" # (optional) required when inclusion is \"auto\"; Kiro auto-includes the file when a request matches this\ntakt: # takt specific parameters (optional; emitted under .takt/facets/policies/ — frontmatter is dropped on emit)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\n facet: \"output-contracts\" # (optional) \"policies\" (default) or \"output-contracts\": redirect this rule to Takt's output-structure/report-template facet\nfactorydroid: # factorydroid specific parameters\n # Route this non-root rule's body to one of Factory Droid's fixed files instead\n # of folding it into AGENTS.md / .factory/rules/*.md: \"design\" → the\n # design-guidelines DESIGN.md, \"threat-model\" → the Security Review attack-surface\n # map .factory/threat-model.md (both project scope only); \"output-style\" → a\n # custom output style of its own, .factory/output-styles/<rule file name>.md\n # (project and global scope). This option is available only if root is false.\n channel: \"design\"\n # (optional) With channel: \"output-style\", the style's picker name; Droid\n # defaults it to the file name without .md. Output-style rules should set\n # targets: [\"factorydroid\"] so other tools do not receive them as rules.\n name: \"Review Notes\"\n---\n\n# Rulesync Project Overview\n\nThis is Rulesync, a Node.js CLI tool that automatically generates configuration files for various AI development tools from unified AI rule files. The project enables teams to maintain consistent AI coding assistant rules across multiple tools.\n\n...\n```\n\nMultiple files can set `root: true` for the same target in project and global modes. Rulesync renders each file through the target adapter, then combines compatible root or plain-Markdown single-file outputs in deterministic source-discovery order with one blank line between fragments. Local rules are ordered lexicographically by source file path and composed before non-overridden `.curated/` rules, which are also ordered lexicographically; filename prefixes such as `10-` and `20-` control composition order within each set. Targets that map source rules to distinct native paths keep those files separate. Explicitly supported plain-Markdown modular rules that normalize to the same output path are combined; a fragment whose generated output carries its own frontmatter block (such as Amp's `globs:` gate) is never composed and stays a separate file instead. Unsafe collisions involving a source `root: true` rule fail. Other exact or case-insensitive modular collisions remain separate and produce a warning that the last write wins wherever the filesystem treats their paths as the same.\n\n> **localRoot import note:** For the tools that emit a separate personal local file (Claude Code and its legacy layout: `CLAUDE.local.md`; Rovodev, Roo Code, Zoo Code and Devin: `AGENTS.local.md`; CodeBuddy Code: `CODEBUDDY.local.md`; Crush: `CRUSH.local.md`; Reasonix: `REASONIX.local.md`; Qwen Code: `.qwen/QWEN.local.md`), `rulesync import` also reads that file back as a `localRoot: true` rule under `.rulesync/rules/`, keeping the tool-side basename. The imported rule's `targets` is scoped to the tool it was imported from, not `\"*\"` — a wildcard would spread the personal content into other tools' committed root files on the next generate (tools without a separate local file append `localRoot` bodies to their root file), and importing from several tools would otherwise produce conflicting wildcard `localRoot` rules. Widen `targets` by hand if you do want the content shared. The same scoping applies to `rulesync convert`: converting to a different tool drops the source tool's personal local file rather than folding it into the destination's root file. The derived `.gitignore` covers the imported copy via `.rulesync/rules/*.local.md`; run `rulesync gitignore` after a first import if the project's `.gitignore` has not been generated yet, so the personal content stays untracked. Project scope only, like `localRoot` generation itself.\n\n> **AGENTS.md standard note (`agentsmd`):** Nested `AGENTS.md` files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from `agentsmd.subprojectPath` and, on **import**, discovers them by scanning the project for `**/AGENTS.md`. The path can also be **derived from the rule's `globs`**: set `agentsmd.subprojectPath: \"auto\"` on one rule, or `deriveSubprojectPathFromGlobs: true` in `rulesync.jsonc` for every non-root rule. The derived directory is the longest leading run of wildcard-free path segments that every glob shares, minus a trailing file name (`[\"packages/api/**/*.ts\"]` → `packages/api`, `[\"path/to/*\"]` → `path/to`), and it must be the same for all of the rule's globs. A wildcard-free final segment is read as a file name, not a directory (`[\"packages/api\"]` → `packages`), so write a directory as `packages/api/**`. Globs with no such prefix (`[\"**/*.ts\"]`, `[\"*.md\"]`, none at all), globs that disagree (`[\"packages/api/**\", \"packages/web/**\"]`), negations (`!…`), absolute paths, `..` segments, and brace expansion in the first segment (`{a,b}/**`) derive nothing: the rule keeps its default modular placement. Only a rule that asked with `\"auto\"` is warned about, once, naming the file; with `deriveSubprojectPathFromGlobs` the fallback is silent, since most rules carry activation globs such as `[\"src/**/*.ts\", \"test/**/*.ts\"]` that were never meant to name a directory. An explicit `agentsmd.subprojectPath` always wins over the derived value, an explicit `\"\"` opts a single rule out of the config option (default placement, nothing derived), and `root: true` rules never nest. Two rules that resolve to the same directory — derived, explicit, or one of each — collide exactly as two explicit `subprojectPath` rules do today (composed in source order for `agentsmd`; see each target's collision policy). Note that `globs` keeps its existing meaning for the targets that read it as a conditional-activation hint (Cursor, Cline, Kiro `fileMatch`, and others): derivation only adds where the file is placed, it does not change what the field means. Derived nested files are written through the same path as explicit ones, so `generate --delete` treats the two identically: it sweeps the `.agents/memories/*.md` file the rule used to produce (run it once after turning the option on), and it leaves the nested file on disk if the rule is later removed, exactly as described above. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are `node_modules/` and `__pycache__/`. Build, vendoring and scratch directories (`vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`) are skipped **at the project root only**, because a top-level `build/` is a build directory while `packages/build/` is a real subproject. Beyond those names, the scan honors your `.gitignore`: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled `.rulesync/rules/` would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the `.gitignore` files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the _directories_ above each file, not the file itself, so the `**/AGENTS.md` entry that `rulesync gitignore` writes for its own output does not disable the scan; the flip side is that ignoring one individual `AGENTS.md` no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled `.rulesync/`. The scan is import-only: a nested file rulesync did not write is never removed by `--delete`. That means deleting the rulesync rule stops the reference from being listed in the root `AGENTS.md`, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to `.rulesync/rules/<directory-with-hyphens>.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. A subproject that would claim the reserved `overview.md` name gets an `-agents` suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites `.rulesync/rules/`, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See <https://agents.md/>.\n\n> **Kiro note:** Kiro reads steering files from `.kiro/steering/*.md` and uses an `inclusion` frontmatter block to decide when each is loaded (`always`, `fileMatch` with a `fileMatchPattern`, `manual`, or `auto` — which auto-includes the file when a request matches its companion `description`, keyed by `name`). Rulesync derives this for non-root steering files: an explicit `kiro.inclusion` block round-trips as-is (carrying `name`/`description` through for `auto`); otherwise specific (non-wildcard) `globs` map to `inclusion: fileMatch` (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. A rule carrying the shared directory-scoping carrier `agentsmd.subprojectPath` is written to `<dir>/AGENTS.md` **instead of** `.kiro/steering/`: Kiro CLI 2.18.0 and IDE 1.0.309 load `AGENTS.md` as steering context from anywhere in the workspace tree, so a directory-scoped rule reaches only the subtree it applies to rather than being flattened into an always-loaded steering file. A nested file is written plain (no `inclusion` block — that frontmatter belongs to `.kiro/steering/*.md`), and imports back as a `kiro`-targeted rule named after its directory (`services/api` → `services-api-kiro.md`). Like every other nested scan, discovery is **import-only**: the matches are hand-authored files outside a rulesync-owned directory, so `generate --delete` never sweeps them. Nesting is project scope only — under `~/.kiro/steering/` there is no workspace tree to scope against, so `subprojectPath` is ignored there. In **global** mode (`--global`), steering is written to `~/.kiro/steering/` with the root rule as `~/.kiro/steering/product.md` (Kiro does not read `~/AGENTS.md`, so the project-scope root `AGENTS.md` is not used at the home level), and global MCP is written to `~/.kiro/settings/mcp.json`.\n\n> **Grok CLI note:** Grok Build writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.grok/AGENTS.md` (global, via `--global`), and non-root rules to `.grok/rules/*.md` (project) / `~/.grok/rules/*.md` (global). Grok scans that directory flat and in name order, alongside the AGENTS.md family — earlier Rulesync versions folded every topic rule into the single root file, which matched Grok 0.2.54 but not the current release, so regenerate to split them back out. Non-root files carry no frontmatter. Because this is a directory Grok defines rather than one Rulesync invented, a project may already have hand-written files there: Rulesync owns it from now on, so `--delete` removes anything in it — `~/.grok/rules/` included, in global mode — that `.rulesync/rules/` does not produce. Move those files into `.rulesync/rules/` first.\n\n> **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under `.kilo/rules/` — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.\n\n> In global mode (`--global`), Kilo's own layout is asymmetric: the root rule goes to `~/.config/kilo/AGENTS.md`, while non-root rules go to `~/.kilo/rules/*.md` — the same `.kilo`-relative path the skills adapter uses in both scopes. Global rules need no `instructions` registration, because Kilo auto-discovers every `~/.kilo/rules/*.md` on config load; writing the files is enough, and no global `kilo.jsonc` is touched by the rules feature.\n\n> **Kimi Code note:** Kimi Code reads `.kimi-code/AGENTS.md` at project scope and `~/.kimi-code/AGENTS.md` at user scope. When `KIMI_CODE_HOME` is set, Rulesync follows Kimi and resolves every global Kimi-specific file (`AGENTS.md`, `mcp.json`, `config.toml`, `skills/`, and `agents/`) under that custom data root; the shared `~/.agents/skills/` and `~/.agents/agents/` discovery roots remain under the user's real home directory. Because Kimi has no dedicated directory for topic-based instruction files, Rulesync folds every non-root rule body into that single file. See the [Kimi Code agents and instruction-files docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html) and [environment-variable docs](https://moonshotai.github.io/kimi-code/en/configuration/env-vars.html). The `kimi-code-plugin` target instead folds every rule into a plugin bundle's `SYSTEM.md` (project scope only; `localRoot` rules are not packaged); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **deepagents note:** deepagents-cli (dcode) reads `.deepagents/AGENTS.md` at project scope and `~/.deepagents/agent/AGENTS.md` at user scope. When `DEEPAGENTS_HOME` is set, Rulesync follows dcode and treats that directory as the profile root itself, so every global deepagents file moves under it with the `.deepagents` segment dropped: `$DEEPAGENTS_HOME/agent/AGENTS.md`, `$DEEPAGENTS_HOME/agent/skills/`, `$DEEPAGENTS_HOME/agent/agents/`, `$DEEPAGENTS_HOME/.mcp.json`, `$DEEPAGENTS_HOME/hooks.json`, and `$DEEPAGENTS_HOME/config.toml`. The `agent` segment is dcode's default agent profile; a different `[agents].default` in `config.toml` is not followed. See [Tool home overrides](./cli-commands.md#tool-home-overrides-win-over-the-output-root-in-global-scope) for how the variable is validated.\n\n> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). The same applies in **global** mode (via `--global`): OpenCode reads `instructions` from the global `~/.config/opencode/opencode.json` too, so global non-root rules are written to `~/.config/opencode/memories/*.md` and registered there (entries relative to the config file's directory, e.g. `memories/style.md`) instead of being dropped. This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under its managed rules directory (`.opencode/memories/`, or `memories/` in the global config) — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.\n\n> **MiMo Code note:** MiMo Code ([XiaomiMiMo/MiMo-Code](https://github.com/XiaomiMiMo/MiMo-Code)) is an OpenCode distribution, so the `mimocode` target reuses the OpenCode behavior above with MiMo's paths: the root rule goes to `AGENTS.md` (global: `~/.config/mimocode/AGENTS.md`), non-root rules to `.mimocode/memories/*.md` (global: `~/.config/mimocode/memories/*.md`), and they are registered in the `instructions` array of `.mimocode/mimocode.json`/`.mimocode/mimocode.jsonc` (global: `~/.config/mimocode/mimocode.json(c)`). MiMo Code resolves a relative `instructions` entry against the project directory rather than the config file's directory, so global entries are written home-rooted (e.g. `~/.config/mimocode/memories/style.md`), which MiMo Code expands.\n\n> **GitLab Duo CLI note:** GitLab Duo reads project [custom rules](https://docs.gitlab.com/user/duo_agent_platform/customize/custom_rules/) from `.gitlab/duo/chat-rules.md` and user rules from `~/.gitlab/duo/chat-rules.md` (global, via `--global`). The file is plain Markdown with no frontmatter and there is no rules directory, so the root rule and every non-root rule are folded into that one file. GitLab Duo also reads `AGENTS.md`, but that file belongs to the `agentsmd` target, so the `gitlabduo` target does not write it.\n\n> **Qwen Code note:** Qwen Code writes the root rule to the auto-loaded `QWEN.md` (project) / `~/.qwen/QWEN.md` (global, via `--global`) as plain Markdown, and non-root rules to its path-based context-rule directory `.qwen/rules/` (project) / `~/.qwen/rules/` (global). Each non-root rule is a Markdown file with optional YAML frontmatter: Rulesync maps `globs` ⇄ Qwen's `paths` (a picomatch glob array) and `description` ⇄ `description`. A rule **with** specific `paths` is _conditional_ — Qwen lazily injects it only when the model touches a matching file — while a rule **without** `paths` (empty or wildcard `**/*`/`*` globs) is a _baseline_ rule loaded at session start and is written as plain Markdown with no frontmatter block. The `.qwen/rules/` directory supersedes the legacy `.qwen/memories/` import surface, so each rule is emitted to exactly one location; the root `QWEN.md` is unchanged. A `localRoot: true` rule is emitted to `.qwen/QWEN.local.md` (project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the shared `QWEN.md` so it can override team instructions; the file is covered by the derived `.gitignore` since Qwen Code does not gitignore it for you. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code).\n\n> **Cline note:** Cline writes the root rule to the auto-loaded `AGENTS.md` (project) as plain Markdown, and non-root rules to its flat `.clinerules/` directory. Each non-root rule is a Markdown file with optional YAML frontmatter for conditional activation: Rulesync maps `globs` ⇄ Cline's `paths` (a glob array; the rule loads only when a matching file is in context) and `description` ⇄ `description`. A rule with **specific** `globs` emits `paths`; a rule with **universal** globs (`**/*` or `*`) emits `alwaysApply: true` (always load); a rule **without** globs is written as plain Markdown with no frontmatter block (always active). In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` (Cline CLI v3.0.15+) as plain Markdown, and non-root rules go to `~/Documents/Cline/Rules/*.md` — the global modular-rules directory both the VS Code extension and the SDK/CLI read — with the same conditional-frontmatter conversion project rules get. See the [Cline rules docs](https://docs.cline.bot/customization/cline-rules).\n\n> **Roo / Zoo Code note (rules):** At project scope both tools write the root rule to the auto-loaded workspace-root `AGENTS.md` and non-root rules to `.roo/rules/*.md`. In global mode (via `--global`) the root rule goes to **`~/.roo/rules/AGENTS.md`**, not `~/AGENTS.md`: the agent-rules loader discovers `AGENTS.md` / `AGENT.md` / `AGENTS.local.md` in the **workspace only** (the current directory, plus subdirectories holding a `.roo` folder when `enableSubfolderRules` is on), so a home-directory `AGENTS.md` is never read, while `~/.roo/rules/` is loaded before the workspace rules. The root rule therefore joins the non-root files in the directory the extension does load, and keeps the `AGENTS.md` basename so it still reads as the root overview — which is also what identifies it as the root rule on `import --global`. A `localRoot` rule stays project-only: `AGENTS.local.md` is a workspace file, so global generation skips it with a warning. See the [Roo Code custom instructions docs](https://roocodeinc.github.io/Roo-Code/features/custom-instructions).\n\n> **Response language note:** When `rulesync.jsonc` sets the root `language` key, the generated **root** rule file (the one built from the `root: true` rule — `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.cursor/rules/overview.mdc`, ...) ends with a thematic break followed by `You must always answer in <language>. On the other hand, reasoning (thinking) should be in English to improve token efficiency.` The block is body content, never frontmatter, so it works for every format; nested rules do not get it. Claude Code is the exception: it has a native `language` setting, so Rulesync writes `\"language\": \"<name>\"` into `.claude/settings.local.json` (project) or `~/.claude/settings.json` (global) through the shared settings gateway instead of touching `CLAUDE.md`. On import the block is recognized for every supported language and stripped from the rulesync rule, so the language lives in `rulesync.jsonc` alone. See [Response Language](../guide/configuration.md#response-language).\n\n> **Cursor note (rules):** Cursor's Always Apply template is `alwaysApply: true` with `globs` omitted, and the [Cursor rules docs](https://cursor.com/docs/rules) say globs are ignored once that flag is set. The two together are a semantic conflict Cursor's own staff advise against — some versions classify such a rule by its glob and treat it as Agent Requested instead of Always — so Rulesync keeps them apart on both sides of the round trip. **Import** keeps the canonical `globs: [\"**/*\"]`, so the tools that have no `alwaysApply` equivalent still apply the rule everywhere, but records `cursor.globs: []`. **Generate** drops a _universal_ glob (`**/*` or `*`) from an Always Apply rule wherever it came from, so the `.mdc` goes back out with no `globs` line. A **specific** glob is kept even alongside the flag: it says something the flag cannot, so it stays the author's to keep. An earlier Rulesync version invented `globs: **/*` on import, so an import-then-generate round trip rewrote hand-written Always Apply rules; just regenerate after upgrading and the stray glob comes back out, whether it sits in `cursor.globs` or in the canonical `globs`. Rules that are not Always Apply are untouched: an empty `globs` stays empty for both fields.\n\n> The two ways `cursor.globs` can be absent mean different things, here and everywhere else a tool-specific `globs` appears. **Omitting** the key means \"no Cursor-specific opinion\", so the canonical `globs` is used; an explicit **`[]`** means \"this rule has no Cursor globs\" and overrides the canonical value. That override is one-way and silent — a rule with canonical `globs: [\"docs/**\"]` and `cursor: { alwaysApply: true, globs: [] }` applies to the whole project in Cursor while the canonical file reads as if it were scoped to `docs/`. Deleting `cursor.globs` therefore does not by itself remove a glob — the canonical value takes over instead. On an Always Apply rule that is invisible when the canonical value is universal, since generate drops it either way, but a _specific_ canonical glob comes back out. Note also that `cursor.globs: []` on a rule that has no `alwaysApply` leaves `description` as the only frontmatter key, which makes the rule Agent Requested — Cursor reads the description and decides whether to pull the rule in. Only a rule with no description either ends up with empty frontmatter, which is Apply Manually: it applies solely when it is `@`-mentioned.\n\n> Cursor rules are **project scope only**. The [Cursor rules docs](https://cursor.com/docs/rules) list four kinds of rules — Project Rules in `.cursor/rules`, User Rules, Team Rules and `AGENTS.md` (written by the `agentsmd` target, not `cursor`) — and none of them is a file in the home directory: User Rules are entered in Cursor's **Customize → Rules** settings and Team Rules are managed from the Cursor dashboard, with no documented on-disk format or API that Rulesync could write. The [Cursor CLI docs](https://cursor.com/docs/cli/using) likewise document only `.cursor/rules` plus the project-root `AGENTS.md` and `CLAUDE.md`. There is therefore no `~/.cursor/rules` equivalent to generate, and `rulesync generate --global --targets cursor --features rules` skips the feature with a warning instead of writing a directory Cursor never reads. For rules that should apply in every project, paste them into User Rules by hand, or generate global rules for a tool that has a home-directory rules file (see the [supported tools matrix](./supported-tools.md)).\n\n> **Warp note (rules):** Warp reads project rules from the root `AGENTS.md` (or the back-compat `WARP.md`) and does not scan a modular rules directory, so non-root rule bodies are folded into the single root `./AGENTS.md`. Directory-scoped rules are the exception: Warp also reads `AGENTS.md` files placed in subdirectories \"for more targeted guidance\", applying the root file plus the current directory's one, with the subdirectory's rules taking precedence over the root's — so a non-root rule carrying `agentsmd.subprojectPath` (explicit, or derived from `globs` via `\"auto\"` / `deriveSubprojectPathFromGlobs`) is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. Note the narrower reach this buys: Warp reliably applies a nested file only while the current directory is that subdirectory (other subdirectories are included on a best-effort basis when you edit files there), whereas a folded rule is read from anywhere in the project — keep a rule unscoped if it must always apply. Because Warp loads those files itself, the root `AGENTS.md` carries no reference list pointing at them. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`) and land in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`, exactly as described in the Vibe Code note below — and, likewise, a nested file emitted from a rule scoped with `targets: [\"warp\"]` is still read by every other AGENTS.md reader working in that directory. In global mode (via `--global`), the root rule is written to the cross-tool `~/.agents/AGENTS.md` — Warp's third rule source alongside project and Warp Drive rules, also used from remote hosts in SSH sessions — with the same folding (a directory-scoped rule folds there too, since the global root has no workspace to nest under). Warp gives a legacy `WARP.md` priority over `AGENTS.md` in the same directory, so when a `WARP.md` exists next to the generated root or nested `AGENTS.md`, Warp never reads that `AGENTS.md`; rulesync warns on generate but neither writes nor deletes `WARP.md` — move its content into `.rulesync/rules/` and remove or rename it. On import, a project-root `WARP.md` is read as the root rule when the root `AGENTS.md` is absent, so a repository that has not moved to `AGENTS.md` yet imports its rules; when both exist, `AGENTS.md` is imported and a warning says that Warp reads `WARP.md` instead. A nested `WARP.md` is not imported. Other targets (e.g. Cline) own the same global path; as with the shared project-root `AGENTS.md`, each target regenerates the file per its own semantics. See the [Warp rules docs](https://docs.warp.dev/agents/capabilities/rules/) and [file locations](https://docs.warp.dev/terminal/settings/file-locations/).\n\n> **Crush note (rules):** Crush reads project context from the root `CRUSH.md` (it also opportunistically reads `crush.md`/`Crush.md`, their `.local` variants, and the cross-tool `AGENTS.md`/`CLAUDE.md`/`GEMINI.md`/`.cursorrules`/`.cursor/rules/`/`.github/copilot-instructions.md`, all owned by other targets) and does not scan a modular non-root instructions directory, so non-root rule bodies are folded into the single root `./CRUSH.md`. A `localRoot: true` rule is the exception: it is written to `./CRUSH.local.md`, one of Crush's default context paths, rather than folded into the committed root file — project scope only, since Crush's global context paths have no `.local` variant. Crush does not gitignore that file for you, so the derived `.gitignore` covers it. In global mode (via `--global`), the root rule is written to `~/.config/crush/CRUSH.md` with the same folding. See the [Crush config source](https://github.com/charmbracelet/crush/blob/main/internal/config/config.go) and [context-file loading](https://github.com/charmbracelet/crush/blob/main/internal/config/load.go).\n\n> **Pi note:** Pi writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.pi/agent/AGENTS.md` (global, via `--global`) as plain Markdown, and folds non-root rules into that single file (Pi has no modular rules directory). Pi additionally loads two system-prompt instruction files. `.pi/APPEND_SYSTEM.md` (project) / `~/.pi/agent/APPEND_SYSTEM.md` (global) **appends** to the default system prompt, and Rulesync emits it from any rule that opts in via a `pi.systemPrompt: append` frontmatter block — those rule bodies are routed to `APPEND_SYSTEM.md` instead of `AGENTS.md`, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file (note: if you hand-authored `.pi/APPEND_SYSTEM.md` before this feature existed, `generate --delete` for the `pi` target now treats it as a managed path and removes it unless a rule opts in — import it first to convert it into a canonical rule). The opt-in is ignored on the `root: true` rule, which always stays on `AGENTS.md` (routing the root away would leave the context file without a merge target). `.pi/SYSTEM.md` (project) / `~/.pi/agent/SYSTEM.md` (global) **replaces** the default system prompt entirely — which silently disables Pi's built-in tool instructions — so Rulesync deliberately never emits it and leaves it to be authored by hand. Example:\n>\n> ```yaml\n> ---\n> targets: [\"pi\"]\n> description: \"House style for the system prompt\"\n> pi:\n> systemPrompt: append # routes this rule's body to .pi/APPEND_SYSTEM.md / ~/.pi/agent/APPEND_SYSTEM.md\n> ---\n> ```\n>\n> Pi tries `AGENTS.override.md` before `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md` and `CLAUDE.MD` in every directory it scans (including the global `~/.pi/agent/` one), loading it **instead of** the others from that directory. Set `pi.contextFile: override` on the **`root: true`** rule to emit the root context file under that name — useful when another target owns the shared `AGENTS.md`, or when a `CLAUDE.md` sits next to it and Pi should deterministically prefer Rulesync's output. Because Pi folds every rule body into the root context file, one opted-in root rule decides for the whole Pi output: the flag is applied to every other Pi rule (root ones included), and setting it _only_ on a non-root rule is ignored with a warning — emitting both files would hide everything left in `AGENTS.md`. `AGENTS.override.md` is Pi-exclusive, so it is imported and deleted like the root file, and toggling the flag off cleans it up. The project-root `AGENTS.md` is never deleted on Pi's behalf, with or without the flag: `agentsmd`, `codexcli`, `warp` and others write that same path, so the `pi` target leaves a stale one behind rather than removing another target's output (the global `~/.pi/agent/AGENTS.md` is Pi-exclusive and is still cleaned up). Example:\n>\n> ```yaml\n> ---\n> root: true\n> targets: [\"pi\"]\n> pi:\n> contextFile: override # emits AGENTS.override.md instead of AGENTS.md\n> ---\n> ```\n>\n> See the [Pi usage docs](https://pi.dev/docs/latest/usage) and the [context-file discovery in the Pi source](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/resource-loader.ts).\n\n> **oh-my-pi note:** oh-my-pi (`omp`, [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi)) is a Pi fork with its own `.omp/` configuration tree. Rulesync writes the root rule as plain Markdown to `.omp/AGENTS.md` (project) / `~/.omp/agent/AGENTS.md` (global, via `--global`), the native context file, which oh-my-pi reads in place of a root `AGENTS.md` at the same depth, and each non-root rule to `.omp/rules/<name>.md` / `~/.omp/agent/rules/<name>.md` with `description`, `globs` and `alwaysApply` frontmatter. oh-my-pi drops a rule that is neither `alwaysApply: true` nor described, so a rule whose `globs` are empty or match everything (`**/*`) is written as `alwaysApply: true`, and a glob-scoped rule without a `description` gets one generated from its globs. oh-my-pi reads only the top level of its rules directory, so a nested rule (`.rulesync/rules/frontend/style.md`) is flattened to `.omp/rules/frontend-style.md`. Import reads the top-level `*.md` and `*.mdc` files oh-my-pi reads, and an imported `.mdc` rule becomes a `.md` rulesync rule; generation always writes `.md`, so delete the original `.mdc` file after importing it (oh-my-pi treats `foo.md` and `foo.mdc` as the same rule). Flattening does not round-trip: importing `.omp/rules/frontend-style.md` creates `.rulesync/rules/frontend-style.md` beside the nested source rule, so keep only one of them. Nested files under `.omp/rules/` are not imported (oh-my-pi does not read them), but `generate` with `--delete` still removes nested `.md` files there, which earlier Rulesync versions wrote. `.omp/RULES.md` (sticky rule) and `.omp/SYSTEM.md` (system prompt replacement) are never emitted. Global paths assume the default profile (`~/.omp/agent`); a named profile (`OMP_PROFILE`) or a `PI_CODING_AGENT_DIR` override is not followed. See the [oh-my-pi context-file docs](https://github.com/can1357/oh-my-pi/blob/main/docs/context-files.md).\n\n> **Factory Droid note (rules):** Beyond the root `AGENTS.md` (project) / `~/.factory/AGENTS.md` (global, via `--global`) and non-root rules in `.factory/rules/*.md`, Factory Droid also loads two further fixed files on its own, and Rulesync emits each from any non-root rule that opts in via the `factorydroid.channel` frontmatter key. Opted-in rule bodies are routed to the channel's file instead of `AGENTS.md`/`.factory/rules/*.md`, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file:\n>\n> - `channel: design` → `DESIGN.md` at the project root: \"Always-on design-system, UX, visual, and interaction guidance\", loaded independently of `AGENTS.md`'s coding guidelines.\n> - `channel: threat-model` → `.factory/threat-model.md`: the attack-surface map Factory's Security Review reads (\"if `.factory/threat-model.md` exists, Droid uses it as the attack-surface map\"). On import it lands in `.rulesync/rules/threat-model.md`.\n>\n> Both files are excluded from the TOON reference list Factory Droid's root file otherwise embeds for non-root rules, since Factory Droid already loads them itself and listing them there would double them up. The opt-in is ignored on the `root: true` rule and in global mode: Factory's docs describe root and nested `DESIGN.md` files like `AGENTS.md`, and the threat model only as a repository file, but document no personal/global home-directory equivalent for either. Example:\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Color palette and spacing scale\"\n> factorydroid:\n> channel: design # routes this rule's body to DESIGN.md\n> ---\n> ```\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Trust boundaries and sensitive data flows\"\n> factorydroid:\n> channel: threat-model # routes this rule's body to .factory/threat-model.md\n> ---\n> ```\n>\n> A third value, `channel: output-style`, writes the rule as one of Factory Droid's [custom output styles](https://docs.factory.ai/droid-cli/output-styles) — response-presentation instructions a user picks in `/settings` — instead of a concatenated file: each opted-in rule becomes its own `.factory/output-styles/<rule file name>.md` (`~/.factory/output-styles/` with `--global`, the one channel that also works in global mode, since Factory documents the user scope too). The rule's `description` becomes the style's `description` frontmatter (shown below its name in the picker), and the optional `factorydroid.name` its `name` (the picker name, which Droid otherwise takes from the file name; generate warns when `name` is set without `channel: output-style`, where it is ignored). The file is excluded from the TOON reference list like the other channels, and import brings each style back as `.rulesync/rules/<file name>.md` with the same frontmatter. Because Droid loads only direct `.md` children of `output-styles/` and reserves its built-in `Default` and `Concise` styles, generate fails for an output-style rule nested in a subdirectory of `.rulesync/rules/` or whose style name is `default` / `concise` (case-insensitive). Unlike `.factory/rules/`, the directory is not swept by `generate --delete`: it also holds styles written by hand (including personal ones in `~/.factory/output-styles/`) that Rulesync cannot tell apart from its own, so a style no rule produces anymore — after renaming or removing the rule — has to be deleted by hand (or the next import brings it back as a rule). For the same reason `rulesync gitignore` adds no entry for it, and generate overwrites a hand-written style of the same file name without a warning — with `--global` that includes a personal style in `~/.factory/output-styles/`, so pick a rule file name no style of your own uses. Import brings back every `.md` file in the directory, hand-written styles included, but skips any style reached through a symbolic link — a linked file or a linked `output-styles` directory, at either scope — so a link's target is never copied into `.rulesync/rules/`; a style whose file name matches a `.factory/rules/*.md` rule imports to the same `.rulesync/rules/` path, and only one of the two is kept (a warning names both). Give an output-style rule `targets: [\"factorydroid\"]`, as in the example below: with any other target it also reaches those tools as an ordinary rule, putting presentation instructions in their project rules, and generate warns about it. Selecting the style (the `outputStyle` setting) stays with the user. Example:\n>\n> ```yaml\n> ---\n> targets: [\"factorydroid\"]\n> description: \"Put findings before the summary\"\n> factorydroid:\n> channel: output-style # writes .factory/output-styles/<this file name>.md\n> name: \"Review Notes\"\n> ---\n> ```\n>\n> See the [Factory Droid AGENTS.md configuration docs](https://docs.factory.ai/cli/configuration/agents-md), the [Security Review docs](https://docs.factory.ai/software-factory/security-review) and the [output styles docs](https://docs.factory.ai/droid-cli/output-styles).\n\n> **Devin note:** The root rule is emitted to the project-root `AGENTS.md` — the file [Devin CLI / Devin Local actually reads](https://docs.devin.ai/cli/extensibility/rules) (its rules page does not list `.devin/rules/` among its sources) — as plain markdown, while non-root rules keep going to `.devin/rules/*.md`, the Devin Desktop Cascade directory whose `trigger` activation modes (`always_on`, `glob`, `manual`, `model_decision`) are driven by the `devin` frontmatter block. Global mode mirrors that layout: the root rule is a plain `~/.config/devin/AGENTS.md`, and non-root rules are emitted one file per rule into `~/.devin/rules/*.md` with the same `trigger`/`globs` frontmatter. Note the directory split — the per-rule global directory is the home `~/.devin/`, not the `~/.config/devin/` tree the global root and Devin's other global surfaces use; that is what the rules page documents (`~/.devin/rules/*.md`, `~/.devin/global_rules.md`). The `devin-plugin` target writes the root rule to a plugin bundle's `AGENTS.md` and other rules to its `rules/<name>.md` (project scope only; `localRoot` rules are not packaged). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Amp note:** Amp gates an @-mentioned guidance file on `globs:` YAML frontmatter — the file is loaded only after Amp has read a file matching one of the globs, and **without** the frontmatter it is always loaded. Rulesync therefore emits each non-root rule's `globs` as that frontmatter on the generated `.agents/memories/*.md` file (in addition to the advisory `applyTo` value in the root file's TOON table, which Amp does not enforce), and restores it into the canonical `globs` on import. Amp implicitly prefixes each glob with `**/` unless it starts with `./` or `../`, so canonical globs pass through verbatim. See [Globs in AGENTS.md](https://ampcode.com/news/globs-in-AGENTS.md).\n\n> **JetBrains AI Assistant note:** AI Assistant reads every `.aiassistant/rules/*.md` file (project scope only; there is no root file, so the root rule is written there as its own file too) and stores each rule's type — the \"Rule type\" selector in the IDE — in the file itself, as a leading metadata block: `---` / `apply: <type>` / `---`, where `<type>` is one of `always`, `manually`, `by model decision` (with an `instructions: <condition>` line), `by file patterns` (with a `patterns: <glob>, <glob>` line — comma-separated, not a YAML list) or `off`. The block is line-based (`<field>: <value>`, split on the first colon) rather than YAML, and the values are matched case-sensitively. A file with **no** `apply:` line is typed **Off** when the plugin collects rules for a chat, i.e. it is never attached automatically (the Settings table still shows it as \"Always\"), which is why Rulesync always writes one. The type is derived from the canonical frontmatter unless `aiassistant.apply` sets it explicitly: `globs` with no universal entry become `apply: by file patterns` with the globs as `patterns`; a `description` on a rule without any `globs` becomes `apply: by model decision` with the description (flattened to one line) as `instructions`; everything else — the root rule, any list containing a universal glob (`**/*`, `*`) and rules with neither — is `apply: always`. Because the plugin splits `patterns` on every comma, a brace alternation such as `src/**/*.{ts,tsx}` is expanded into one pattern per branch (`src/**/*.ts, src/**/*.tsx`) before it is written, and a glob that still contains a comma — or any value containing `---`, which the plugin reads as the end of the block — is written with a warning. An explicit `aiassistant.apply` is written verbatim even when it is not one of the five types (with a warning — the current plugin treats such a rule as Off), and an explicit `by file patterns` writes every glob, universal ones included, as `patterns`. On import the block is stripped from the body and mapped back: `by file patterns` → `globs` (split on the commas outside brace groups), `by model decision` → `description`, `always` → `globs: [\"**/*\"]`. `aiassistant.apply` is kept whenever the next generate would not derive the same type on its own — `manually` / `off` and unrecognized values, which have no canonical counterpart, and a `by file patterns` / `by model decision` block whose `patterns` / `instructions` line is missing or universal — so a regenerate reproduces the file. This format comes from the plugin itself (`AiRulesService` and its message bundle, verified on build 262.10968.75) — the [project rules docs](https://www.jetbrains.com/help/ai-assistant/configure-project-rules.html) describe the rule type only as an IDE setting.\n\n> **Trae note:** Trae (ByteDance's AI IDE, called \"TraeCode\" in its docs) reads every `.trae/rules/*.md` file (project scope only; there is no root rule file, so the root rule is written there as its own file too, the same layout as Cursor). Each file carries Cursor's frontmatter triple, which selects one of Trae's four application modes: `alwaysApply: true` (always), `alwaysApply: false` with `globs` (specific files), `alwaysApply: false` with `description` (apply intelligently), and `alwaysApply: false` alone (manual, via `#Rule` in chat). The mode is derived from the canonical frontmatter unless `trae.alwaysApply` sets it: `globs` with no universal entry give `alwaysApply: false` plus the globs, written unquoted and comma-separated as Trae's editor writes them (`globs: *.ts,src/**/*.tsx`), with brace alternations expanded first because Trae splits the value on every comma (`src/**/*.{ts,tsx}` is written as `src/**/*.ts,src/**/*.tsx`; a glob that still holds a comma, such as one past the expansion cap, is written as is with a warning); a `description` on a rule without any `globs` gives `alwaysApply: false` plus the description; everything else — the root rule, any list containing a universal glob (`**/*`, `*`) and rules with neither — is `alwaysApply: true`, written without `globs` (an explicit `trae.alwaysApply: true` keeps the specific globs, and `trae.alwaysApply: false` keeps every glob, universal ones included, so a file-scoped `**/*` rule does not turn into a manual one). On import `alwaysApply: true` without globs maps to `globs: [\"**/*\"]`, an unquoted `globs` value is read literally (quoted before YAML parsing, so `{src,lib}/**` or `!**/test/**` do not break the import; YAML null and boolean keywords and block scalars are left to YAML) and split on the commas outside brace groups (a YAML flow list is accepted too; a value that only starts with `[`, such as the character-class glob `[abc]*.ts`, is read as a glob), and `trae.alwaysApply` is kept only when the next generate would not derive the same value (a manual rule, or `alwaysApply: true` beside specific globs). Trae's `scene` field (`scene: git_message` also applies the rule when Trae generates a Git commit message, as in the `git-commit-message.md` its Source Control panel creates) round-trips through `trae.scene`. As for any rules directory, `generate --delete` removes `.trae/rules/*.md` files that no Rulesync rule produces, so import a hand-made rule such as `git-commit-message.md` first if you want to keep it. Global rules are not generated: Trae documents `~/.trae/user_rules` only as a path, without saying whether it is a file or a directory or what goes in it. Trae also reads a `.trae/rules/` folder in any project subdirectory and applies it when files in that directory are read or mentioned, so a non-root rule carrying `agentsmd.subprojectPath` (explicit or derived from `globs`) is written to `<subprojectPath>/.trae/rules/<name>.md` instead (project scope only), with the same frontmatter mapping and its `globs` left as written. On **import**, those folders are found by a project scan (hidden directories, `node_modules/`, `__pycache__/` and root build/vendoring directories are skipped, symbolic links are not followed, and git-ignored directories are skipped — tested down to `<dir>/.trae/` only, so the `**/.trae/rules/` entry `rulesync gitignore` writes does not disable the scan) and each file lands in `.rulesync/rules/` under its own name (a subfolder inside the nested rules directory is kept, as for the root one) with `targets: [\"trae\"]` and the `subprojectPath` carried, so the next generate puts it back without other tools picking it up; two files with the same name in different rules directories collide, which import reports. Like every other nested scan this is import-only: `generate --delete` never removes a nested `.trae/rules/` file, so delete one by hand after dropping its rule. MCP servers go to the project `.trae/mcp.json` as a plain `mcpServers` object (user-level servers live in the IDE settings UI; other top-level keys already in the file are kept). Trae expands only `${workspaceFolder}`, so any other `${...}` reference in `env` or `headers` (such as `${API_KEY}`) reaches Trae as literal text and Rulesync warns about it. Skills go to `.trae/skills/<name>/SKILL.md` (project) / `~/.trae/skills/<name>/SKILL.md` (global) in the Agent Skills format. See the [Trae rules](https://docs.trae.ai/ide/rules?_lang=en), [skills](https://docs.trae.ai/ide/skills?_lang=en) and [MCP](https://docs.trae.ai/ide/add-mcp-servers?_lang=en) docs.\n\n> **Antigravity note (rules):** The Antigravity IDE and CLI share their project rules: the root rule is the project-root `AGENTS.md`, and every non-root rule is a file in `.agents/rules/` with Antigravity `trigger` frontmatter (`always_on`, `glob` with `globs`, `model_decision` with `description`, or `manual`). Antigravity also reads a `.agents/rules/` directory in any subdirectory and loads it for files under that directory ([rules docs](https://antigravity.google/docs/rules)), so in project scope a non-root rule carrying `agentsmd.subprojectPath` (explicit or derived from `globs`) is written to `<subprojectPath>/.agents/rules/` instead (the root rule and global rules are never nested). Antigravity reads only the direct children of a rules directory, so a nested rule's file name is flattened and keeps its case (`frontend/codeStyle.md` → `frontend-codeStyle.md`), and both targets write the same file. A rule that also targets `agentsmd` gets a `<subprojectPath>/AGENTS.md` as well, which Antigravity reads too. On import, the files directly inside `.agents/rules/` folders in subdirectories are read back as rules with that `agentsmd.subprojectPath` and `targets: [\"antigravity-ide\", \"antigravity-cli\"]`; a folder under a hidden directory, `node_modules`, a build output or a git-ignored directory is skipped, and symlinks are not followed. Two files with the same name in different rules directories (two subprojects, or a subproject and the project root) map to the same `.rulesync/rules/` file and collide, which import reports. Like other nested outputs, these files sit outside the root `.agents/rules/`, so `generate --delete` does not remove them. The `antigravity-plugin` target keeps every rule in its own `rules/` directory.\n\n> **Junie note:** Junie CLI resolves project guidelines in order — `.junie/AGENTS.md` → root `AGENTS.md` combined with `.junie/playbook.md` and every `.junie/rules/*.md` → the legacy `.junie/guidelines.md` / `.junie/guidelines/`. The multi-file branch is unreachable whenever `.junie/AGENTS.md` exists, because that file \"is used exclusively and no other guidelines files are combined with it\". Rulesync therefore writes the root rule to `.junie/AGENTS.md` (project) / `~/.junie/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file — lossless, since Junie loads it in full. Emitting `.junie/rules/*.md` beside it would produce files Junie never reads, and moving the root output to the project-root `AGENTS.md` would change every existing output path and collide with the `agentsmd` target, so the fold stays. The multi-file branch **is** read on import, but only while `.junie/AGENTS.md` is absent — exactly when Junie itself reads it. Hand-authored `.junie/rules/*.md` (that directory itself, not a tree below it) and `.junie/playbook.md` are then imported as non-root rules. They are import-only read roots — Rulesync never generates them, never deletes them as orphans, and never adds them to `.gitignore` — so the `generate` that follows folds their content into `.junie/AGENTS.md` and leaves the originals untouched. Later imports skip them with a single warning naming up to ten skipped files (the rest are folded into an `and N more` count): re-reading rules the root file already contains would fold the same content in again on every import/generate cycle. Only `.junie/AGENTS.md` closes that gate — the legacy `.junie/guidelines.md` does not, since Junie ranks it _below_ the multi-file branch and Rulesync never writes it, so nothing has been folded into it. In that legacy layout both branches are imported at once, so a `.junie/rules/overview.md` and the legacy root claim the same `.rulesync/rules/overview.md`; the root file wins, and the usual collision warning names the file that lost. Delete them once you have checked the fold, since Junie stops reading them the moment `.junie/AGENTS.md` exists. In that pre-`.junie/AGENTS.md` layout Junie's root file is the project-root `AGENTS.md`, which belongs to the `agentsmd` target. `rulesync import` takes one target at a time, so run `rulesync import --targets agentsmd` before `rulesync import --targets junie` if you want a root rule as well: a `junie`-only import of that layout yields non-root rules alone, and the next `generate` writes `.junie/AGENTS.md` — at which point Junie stops reading the project-root `AGENTS.md` whose content was never imported. The legacy `.junie/guidelines.md` is still accepted as an import fallback. Earlier Rulesync versions emitted non-root rules to `.junie/memories/*.md`, which is not a documented Junie read path; those files are no longer generated (stale outputs stay gitignored but are not cleaned up automatically). See the [Junie guidelines docs](https://junie.jetbrains.com/docs/guidelines-and-memory.html) and the [Junie IDE plugin docs](https://junie.jetbrains.com/docs/junie-ide-plugin.html) — the IDE plugin page is where the exclusivity sentence quoted above appears.\n\n> **Reasonix note:** Reasonix auto-injects a hierarchical instruction document, reading its vendor-specific `REASONIX.md` (alongside the cross-tool `AGENTS.md`/`CLAUDE.md`) by walking user-home → ancestors → project root/local. Rulesync writes the vendor `REASONIX.md` at the project root (project) / `~/.reasonix/REASONIX.md` (global, via `--global`) and folds non-root rules into that single file, since Reasonix has no modular rules directory. A `localRoot: true` rule goes to `./REASONIX.local.md` instead (project scope only): Reasonix loads that personal variant beside each `REASONIX.md` in the workspace chain, ahead of it (`LocalDocumentNames` in `internal/instruction/resolver.go`), so the personal body stays out of the committed file; the derived `.gitignore` lists it, and `rulesync import` reads it back as a reasonix-scoped `localRoot` rule. The Reasonix home loads no local variant, so global scope has none. Directory-scoped rules are the exception: Context Engine v2 (v1.18.0) also walks from the workspace root to the target path loading per-directory instruction files (“Deeper directories beat broader directories”), so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/REASONIX.md` (project scope only) instead of being folded — its paragraphs load only under that path rather than being carried on every turn. On **import**, nested `REASONIX.md` files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`) and land in `.rulesync/rules/<directory-with-hyphens>-reasonix.md` with `targets: [\"reasonix\"]` and the `subprojectPath` carried, so the next generate puts them back. The `-reasonix` suffix and the reasonix-only targeting keep them from clobbering the AGENTS.md standard's derived names or surprising other tools with new nested files; note that a rule targeting both `agentsmd` and `reasonix` with a `subprojectPath` produces a nested `AGENTS.md` **and** a nested `REASONIX.md` in the same directory, both of which Reasonix loads — scope such rules to one target. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md) and [Context Engine v2 docs](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.18.0/docs/SESSION_MEMORY_RETRIEVAL.md).\n\n> **Vibe Code note:** Vibe reads the project-root `AGENTS.md` (project) / `~/.vibe/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Vibe's harness manager walks the directories between the workspace root and the file being read and loads every `AGENTS.md` it finds along the way (`find_subdirectory_agents_md`), injecting the result into the `read_file` tool's output since v2.19.1 — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`). Unlike the Reasonix note above, the imported rule is **not** suffixed or target-scoped: Vibe's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]` — importing the same file through `agentsmd` and `vibe` therefore yields one rulesync rule, not two copies that would fold duplicated content back into the same `AGENTS.md`. Because the emitted file is the plain per-directory `AGENTS.md`, a rule scoped with `targets: [\"vibe\"]` is still picked up by every other AGENTS.md reader working in that directory — Vibe's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/harness_files/_harness_manager.py`).\n\n> **Meta Muse Code note:** Muse Code walks up from the working directory to the `.git` boundary and loads one instruction file per directory level, preferring `AGENTS.md` over `CLAUDE.md` when both exist. The `musecode` target writes the root rule to the shared project-root `AGENTS.md` (the same file `agentsmd`, `codexcli` and others write) and folds non-root rules into it, since Muse Code has no modular rules directory. Muse Code has user/global rules, but their path is not documented, so the `musecode` rules target is project-scope only. See the [Muse Code configuration docs](https://dev.meta.ai/docs/muse-code/configuration.md).\n\n> **ZCode note:** ZCode (Z.ai's agentic development environment for the GLM family) reads exactly two instruction files — the user-global `~/.zcode/AGENTS.md` and the workspace `AGENTS.md` at the project root — and appends them in that order. Its docs are explicit that it \"does not merge multiple `AGENTS.md` files across directory levels\" and \"does not scan child directories\", so there is no nested rules surface to emit: rulesync writes the project-root `AGENTS.md` (project) / `~/.zcode/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file. `CLAUDE.md` is deliberately not written for `zcode`: ZCode reads it only once, during onboarding, as a migration source. See the [ZCode AGENTS.md docs](https://zcode.z.ai/en/docs/agents).\n\n> **OpenClaw note:** OpenClaw (the always-on assistant gateway) injects its agent workspace's `AGENTS.md` into every session — it is also the only bootstrap file sub-agent sessions receive — and, when a session runs from another folder or a managed worktree, appends that folder's `AGENTS.md` as project context. Rulesync writes the project-root `AGENTS.md` (project) / `~/.openclaw/workspace/AGENTS.md` (global, via `--global` — only the default workspace; `OPENCLAW_WORKSPACE_DIR`, `OPENCLAW_PROFILE`, `OPENCLAW_STATE_DIR`, `agents.defaults.workspace` and per-agent `agents.entries.*.workspace` settings that relocate it are not followed) and folds non-root rules into that single file, since OpenClaw documents no per-directory walk (when OpenClaw runs on the native Codex harness, Codex's own project-doc discovery reads the execution folder's `AGENTS.md` instead). OpenClaw seeds the workspace `AGENTS.md` with its own template on onboarding, and `rulesync generate --global` replaces that file wholesale — run `rulesync import --targets openclaw --global` first if you want to keep its content. The workspace's other prompt files (`SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`) are never touched. OpenClaw truncates each bootstrap file past `agents.defaults.bootstrapMaxChars` (20,000 characters by default) and all bootstrap files together past `agents.defaults.bootstrapTotalMaxChars` (60,000 by default), and injects a short missing-file marker when `AGENTS.md` is absent — so a `--delete --global` run that no longer produces any rule for `openclaw` removes the file and leaves the agent without its operating instructions. For skills, the execution folder's `.agents/skills/` and the personal `~/.agents/skills/` are served by the `agentsskills` target; the workspace-local `~/.openclaw/workspace/skills/` and `~/.openclaw/workspace/.agents/skills/` roots are not written. See the [OpenClaw system prompt](https://docs.openclaw.ai/concepts/system-prompt), [agent workspace](https://docs.openclaw.ai/concepts/agent-workspace) and [skills](https://docs.openclaw.ai/tools/skills) docs.\n\n> **Codex CLI note:** Codex CLI reads the project-root `AGENTS.md` (project) / `~/.codex/AGENTS.md` (global, via `--global`) and folds non-root rules into that single file, since it has no modular rules directory (`.codex/memories/` belongs to its separate auto-memory system). Directory-scoped rules are the exception: Codex loads one `AGENTS.md` per directory from the project root down to the working directory, deeper files later in the prompt — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. A rule scoped only by `globs` (e.g. `[\"services/svc1/**\"]`) is still folded unless you opt in with `agentsmd.subprojectPath: \"auto\"` on the rule or `deriveSubprojectPathFromGlobs: true` in `rulesync.jsonc` (see the AGENTS.md standard note above for how the directory is derived). Nesting matters for size: Codex stops reading project `AGENTS.md` files once their combined size on the root-to-working-directory chain reaches `project_doc_max_bytes` (32 KiB by default) and drops the rest without telling you, so `rulesync generate` warns in project scope when the generated root `AGENTS.md` — or the root plus the nested files on one directory chain — exceeds 32,768 bytes (measured per output root, so files Codex would also load from directories above an output root are not counted). Rulesync cannot author `project_doc_max_bytes` yet; if you raised it in `.codex/config.toml` yourself, the warning still uses the default and can be ignored. The personal `~/.codex/AGENTS.md` is loaded outside that budget and is not checked. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`, so a nested file stays on disk after you drop the rule's `subprojectPath` — remove it by hand) and land in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`, exactly as described in the Vibe Code note above — and, likewise, a nested file emitted from a rule scoped with `targets: [\"codexcli\"]` is still read by every other AGENTS.md reader working in that directory. See the [Codex AGENTS.md guide](https://learn.chatgpt.com/docs/agent-configuration/agents-md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) reads the project-root `AGENTS.md` (project) / `~/.config/poolside/AGENTS.md` (global, via `--global` — only the XDG-default path; Pool itself honours `XDG_CONFIG_HOME` upstream) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Pool loads every `AGENTS.md` from the repository root down through the working directory, deeper files taking precedence — so a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`). Like the Vibe Code note above, the imported rule is **not** suffixed or target-scoped: Pool's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`. Because the emitted file is the plain per-directory `AGENTS.md`, a rule scoped with `targets: [\"pool\"]` is still picked up by every other AGENTS.md reader working in that directory — Pool's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See the [Pool AGENTS.md instructions](https://docs.poolside.ai/agent-instructions).\n\n> **DeepSeek Harness note:** DeepSeek Harness (`dsh`, DeepSeek's open-source agent harness) loads the user-global `$DSH_HOME/AGENTS.md` and then the project chain of instruction files from the project root (the nearest ancestor holding `.git`) down to the session cwd, broad to specific. Rulesync writes the project-root `AGENTS.md` (project) / `~/.dsh/AGENTS.md` (global, via `--global` — only the `$DSH_HOME` default path) and folds non-root rules into that single file, since the harness has no modular rules directory; the user-global file has no `.local.md` overlay upstream, so global scope is root-only. Directory-scoped rules are the exception: a non-root rule carrying `agentsmd.subprojectPath` is emitted as a nested `<subprojectPath>/AGENTS.md` (project scope only) instead of being folded, because the harness loads it only while working under that directory. On **import**, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by `--delete`), and like the Pool note above the imported rule is **not** suffixed or target-scoped — it is literally the AGENTS.md standard's per-directory file, so it lands in `.rulesync/rules/<directory-with-hyphens>.md` with `targets: [\"*\"]`. The harness also reads `CLAUDE.md` and the `AGENTS.local.md` / `CLAUDE.local.md` overlays at every level of the project chain, collapsing a sibling that duplicates its `AGENTS.md`; those are the `claudecode` target's files, and rulesync's `dsh` target emits `AGENTS.md` only. DeepSeek Harness is a pre-1.0 developer preview, so these paths may move before its first stable release. See the [agent-instructions README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/context/agent-instructions/README.md).\n\n> **Claude Code root file note:** Claude Code reads `AGENTS.md` instead of `CLAUDE.md` when the working directory (or any directory above it) has no `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md`, while `.claude/rules/*.md` keeps loading alongside `AGENTS.md` ([Claude Code memory docs](https://code.claude.com/docs/en/memory#when-claude-code-reads-agents-md)). When another target such as `agentsmd` or `codexcli` already writes the project-root `AGENTS.md`, set `\"includeRoot\": false` on the `claudecode` target's `rules` options to skip the root `CLAUDE.md` and the personal `CLAUDE.local.md` while still generating `.claude/rules/*.md`. With `delete` enabled, a `CLAUDE.md` or `CLAUDE.local.md` left over from an earlier run at the project root is removed; a hand-authored `.claude/CLAUDE.md` is never deleted, but a warning points out that it still shadows `AGENTS.md`. Without `delete`, nothing is removed: a warning lists every project-root `CLAUDE.md` / `CLAUDE.local.md` and `.claude/CLAUDE.md` that still exists, and you must remove them by hand for Claude Code to read `AGENTS.md`. A `root: true` or `localRoot: true` rule whose `targets` name only `claudecode` is dropped with a warning naming the file, since no other target generates it; add the target that writes `AGENTS.md` to its `targets` or turn it into a non-root rule. `ruleDiscoveryMode` has no effect with this option, since the references it adds are written into `CLAUDE.md`. The option applies to project scope only: in global mode `~/.claude/CLAUDE.md` does not shadow `AGENTS.md`, so it is still generated (with a warning). Keep it `true` (the default) when some of your Claude Code sessions do not read `AGENTS.md` — `AGENTS.md` support arrived in v2.1.277 and is unavailable in some setups; see [when AGENTS.md support is unavailable](https://code.claude.com/docs/en/memory#when-agents-md-support-is-unavailable). Example:\n>\n> ```jsonc\n> // rulesync.jsonc\n> {\n> \"targets\": {\n> \"agentsmd\": [\"rules\"],\n> \"claudecode\": { \"rules\": { \"includeRoot\": false } },\n> },\n> }\n> ```\n\n> **CodeBuddy Code note:** CodeBuddy Code (Tencent Cloud's terminal coding agent, `@tencent-ai/codebuddy-code`) mirrors Claude Code's configuration surface closely. Rulesync writes the root rule to `CODEBUDDY.md` at the project root (also read from `.codebuddy/CODEBUDDY.md` as an alternative root) / `~/.codebuddy/CODEBUDDY.md` (global, via `--global`), and non-root rules to `.codebuddy/rules/*.md` (project) / `~/.codebuddy/rules/*.md` (global), auto-loaded without needing a reference section. A personal, gitignored `CODEBUDDY.local.md` is materialized next to the root file, mirroring Claude Code's `CLAUDE.local.md`. Non-root frontmatter supports `description`, `paths` (glob patterns gating when the rule is loaded, accepted as a single string or a list; a string is split on the commas between patterns, leaving `{a,b}` brace groups intact, so the documented `paths: {src,lib}/**/*.ts, tests/**/*.test.ts` form imports as two globs), `alwaysApply` and `enabled`, closer to the Cursor rule model than Claude Code's `paths`-only schema; put tool-specific overrides in the `codebuddy:` block of the canonical rule. Unlike Cursor, CodeBuddy's documented `alwaysApply` default is **`true`**: `paths` only scopes a rule when `alwaysApply: false` sits alongside it, and `alwaysApply: false` with no `paths` makes CodeBuddy drop the rule entirely. Rulesync applies those rules for you — a rule with non-universal globs is emitted with an explicit `alwaysApply: false`, and an `alwaysApply: false` with nothing to trigger on is left implicit rather than written out. The shipped CLI is looser than its docs table and already treats `paths` without `alwaysApply` as a path-triggered rule, so **import** follows the CLI: such a file becomes a rule scoped to those globs, and `alwaysApply` is carried over only when the file spells it out. `enabled: false` stops CodeBuddy loading the rule at all and survives an import/generate round trip. See the [CodeBuddy Code memory docs](https://www.codebuddy.ai/docs/cli/memory) and the [`.codebuddy` directory docs](https://www.codebuddy.ai/docs/cli/codebuddy-dir).\n\n> **IBM Bob note:** IBM Bob (IBM's agentic coding assistant, available as an IDE extension and the Bob Shell CLI) reads the project-root `AGENTS.md` as its primary instruction file and every Markdown file under `.bob/rules/` as additional always-on rules; the user-level counterparts are `~/.bob/AGENTS.md` and `~/.bob/rules/`. Rulesync writes the root rule to `AGENTS.md` (project) / `~/.bob/AGENTS.md` (global, via `--global`) and non-root rules to `.bob/rules/*.md` (project) / `~/.bob/rules/*.md` (global). Bob rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the [rules docs](https://bob.ibm.com/docs/ide/configuration/rules).\n\n> **JetBrains Junie note:** Junie runs a custom slash command only once every `$name` argument in its template is provided, so the universal `$ARGUMENTS` is translated to Junie's free-form `$prompt` argument and `allowPromptArgument: true` is added to the generated `.junie/commands/*.md` frontmatter (and reversed on import — see [Command Syntax](./command-syntax.md) for the exact conditions). Set `junie.allowPromptArgument` in the rulesync frontmatter to override the derived value; `false` keeps `$ARGUMENTS` verbatim. Other keys in the `junie` block are passed through to the Junie frontmatter. See the [custom slash commands docs](https://junie.jetbrains.com/docs/custom-slash-commands.html).\n\n> **Tabnine CLI note:** The file-based Tabnine CLI is in maintenance mode since Tabnine 6.6 (2026-09-15; \"Tabnine CLI (Legacy)\", `tabnine-legacy`, critical updates only through 2026-12-31), and the new `tabnine` command is an OpenCode distribution covered by `--targets opencode` — see [Supported tools > Deprecation notes](./supported-tools.md#deprecation-notes). The legacy Tabnine CLI loads the project-root `TABNINE.md` as its memory file on every session (refreshed with `/memory reload`), so Rulesync writes the root rule there. Non-root rules are written to `.tabnine/guidelines/*.md`, the guideline directory the Tabnine agent reads; the CLI does not load that directory on its own, so `TABNINE.md` also lists the guideline files as a table of contents (the same table-of-contents mode used for tools whose non-root rules are not auto-loaded). In global mode (`--global`) the root rule goes to `~/.tabnine/agent/TABNINE.md` — the user-level memory file the [migration guide](https://docs.tabnine.com/main/getting-started/tabnine-plugin-for-opencode/migrating-from-tabnine-cli) names as \"the global `~/.tabnine/agent/TABNINE.md` file\", next to the other user-scoped agent assets under `~/.tabnine/agent/` — and non-root rules to `~/.tabnine/guidelines/*.md`. Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. Because `.tabnine/guidelines/` is a directory Tabnine defines rather than one Rulesync invented, a project may already have hand-written guidelines there: Rulesync owns it from now on, so `--delete` removes anything in it — `~/.tabnine/guidelines/` included, in global mode — that `.rulesync/rules/` does not produce. Move those files into `.rulesync/rules/` first. See the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference) (`context.fileName`) and the [guidelines docs](https://docs.tabnine.com/main/getting-started/tabnine-agent/guidelines).\n\n> **Snowflake Cortex Code note:** Snowflake Cortex Code (the `cortex` CLI, also called \"CoCo\") reads the project-root `AGENTS.md` as its instruction file. It documents no modular rules directory and no user-level instruction file, so Rulesync folds every non-root rule into the single `AGENTS.md` (the same fold mode Codex CLI uses for rules that are not directory-scoped) and the `rules` feature is project-only (`--global` is not supported). Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the [Cortex Code overview](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code).\n\n> **Codewhale note:** Codewhale reads the workspace-root `AGENTS.md` as its project instruction file and then every `.codewhale/rules/*.md` file in filename order, so Rulesync writes the root rule to `AGENTS.md` and each non-root rule to `.codewhale/rules/<name>.md`. Codewhale does not walk into nested subproject `AGENTS.md` files, so `agentsmd.subprojectPath` is not honored. In global mode (`--global`), Codewhale prepends `~/.codewhale/AGENTS.md` to the project instructions and has no user-level rules directory, so every global rule is folded into that single file.\n\n> **Continue note:** Continue is end of life (it joined Cursor on 2026-06-18; the repository is read-only after the final v2.0.0 release), so the `continue` target is frozen at that final source tree — see [Supported tools > Deprecation notes](./supported-tools.md#deprecation-notes). Continue (the open-source IDE extension and `cn` CLI) reads the project-root `AGENTS.md` as an always-applied instruction file and every Markdown file under `.continue/rules/` (project) / `~/.continue/rules/` (global, via `--global`) as a rule with optional YAML frontmatter, so Rulesync writes the root rule to `AGENTS.md` and non-root rules to `.continue/rules/*.md`. Continue documents no user-level `AGENTS.md`, so in global mode the root rule is written as `~/.continue/rules/AGENTS.md` and told apart from the other rules by its basename (the convention the Roo Code target also uses). A non-root rule carries `description`, `globs` and the Continue-specific `regex` / `alwaysApply` keys: a rule with no `globs`/`regex` is always applied, one with `globs` is applied when a file in context matches, `alwaysApply: true` forces it on and `alwaysApply: false` makes it depend on `globs`/`regex` only. The universal globs `**/*` and `*` are therefore dropped on generate (a globless rule is already always-on), as is any glob next to `alwaysApply: true`. The `continue` frontmatter block (`name`, `alwaysApply`, `regex`, plus `globs` and `description` overrides of the canonical keys) authors those fields; on import a globless always-on rule maps to `globs: [\"**/*\"]`, `alwaysApply: false` without `globs` maps to `globs: []`, and `name` / `alwaysApply` / `regex` are lifted into the `continue` block. See the [rules docs](https://docs.continue.dev/customize/deep-dives/rules).\n\n> **Command Code note:** Command Code (the open-source terminal coding agent published as `command-code` on npm) reads the project-root `AGENTS.md` as its instruction file and the user-level `~/.commandcode/AGENTS.md` (global, via `--global`). An `AGENTS.md` in a subdirectory is only loaded lazily once the agent touches a file beneath it, so there is no deterministic nested rules surface and Rulesync folds every non-root rule into the single `AGENTS.md` (the same fold mode Codex CLI uses for rules that are not directory-scoped). The file is plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and only the file name survives an import. See the [memory docs](https://commandcode.ai/docs/memory).\n\n> **Qoder note:** Qoder (Alibaba's agentic coding platform: the Qoder IDE, the `qodercli` terminal agent and the JetBrains plugin, which share one `.qoder/` layout) reads the project-root `AGENTS.md` plus the per-machine `AGENTS.local.md` overlay (written for a `localRoot: true` rule), and auto-loads the topic rules under `.qoder/rules/*.md`; the user scope is `~/.qoder/AGENTS.md` and `~/.qoder/rules/*.md` (global, via `--global`). Rulesync writes the root rule as plain Markdown to `AGENTS.md` and each non-root rule to `.qoder/rules/<name>.md` with a `trigger` frontmatter: specific `globs` become `trigger: glob` with a `glob` list, and a rule with no globs or a match-everything glob (`**/*`) becomes `trigger: always_on`. A `description` is carried along. Set `qoder.trigger` (`always_on`, `glob`, `manual` or `model_decision`, where Qoder needs a non-empty `description`) and optionally `qoder.glob` in the rulesync frontmatter to pick the mode yourself; a `glob` trigger without specific globs is written as `always_on` with a warning, and a `model_decision` trigger without a description is written with a warning. On import the compatibility spellings are read too (`alwaysApply: true` / `false` as `always_on` / `manual`, as Qoder documents them, a bare `paths` or `glob` list as `glob`); a `manual` or `model_decision` rule keeps its mode in the `qoder` block, and so does any frontmatter key rulesync does not map. A `glob` rule with an empty glob list, which Qoder never applies, is imported with `qoder.trigger: glob` and a warning; the next generate writes it as `always_on`, so add `globs` or set `qoder.trigger: manual` to keep it inactive. See the [memory docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/memory).\n\n## `.rulesync/hooks.jsonc`\n\n`.rulesync/hooks.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/hooks.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nA key named `__proto__`, `constructor`, or `prototype` is rejected with an error naming its path, rather than being dropped in silence. The \"Rejected keys\" paragraph in the `.rulesync/permissions.jsonc` section below describes this handling, which is the same for all three source files.\n\nHermes Agent accepts native snake-case events under `hermesagent.hooks`: `pre_tool_call`, `post_tool_call`, `transform_terminal_output`, `transform_tool_result`, `transform_llm_output`, `pre_llm_call`, `post_llm_call`, `on_stream_start`, `on_stream_delta`, `on_stream_end`, `on_interim_message`, `pre_verify`, `pre_api_request`, `post_api_request`, `api_request_error`, `pre_auxiliary_call`, `post_auxiliary_call`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `on_skill_lifecycle`, `subagent_start`, `subagent_stop`, `pre_gateway_dispatch`, `post_gateway_admission`, `agent_loop_stopped`, `pre_approval_request`, `post_approval_response`, `on_human_input_request`, `on_human_input_resolved`, `on_room_member_activity`, `pre_transcription`, `kanban_task_claimed`, `kanban_task_completed`, `kanban_task_blocked`, `on_kanban_worker_spawned`, `on_kanban_worker_exited`, `on_kanban_worker_stale_claim`, `on_kanban_task_updated`, `on_kanban_dispatch_tick`, `gateway_platform_event`, and `pre_command`. That is 43 of the 44 `VALID_HOOKS` entries at v0.21.6 (`agent_loop_stopped` and `on_room_member_activity` arrived in v0.21, `pre_auxiliary_call` and `post_auxiliary_call` in v0.21.5, `post_gateway_admission`, `on_human_input_request` and `on_human_input_resolved` in v0.21.6): `transform_api_error_classification` is subtracted by `SHELL_UNSUPPORTED_HOOKS`, because a shell hook cannot return its directive and Hermes refuses the registration — authoring it still emits the entry, with a warning saying it will never run. Rulesync maps shared canonical events first, applies canonical keys from `hermesagent.hooks` next, then applies exact native keys last. An exact native key therefore wins when both forms resolve to the same Hermes event. Native-only events remain under `hermesagent.hooks` on import instead of leaking into other targets. Rulesync owns the event keys inside the `hooks:` mapping of `config.yaml`, but not the mapping itself: Hermes v0.20.0 nests the [outbound webhook registry](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) under the same key as `hooks.outbound`, so any key there that is not a Hermes hook event is carried over from the existing file untouched. Rulesync neither authors nor imports `outbound`, since it is a list of webhook targets rather than a hook event; it only makes sure a regenerate leaves it alone. An event key Rulesync did write, including one under an undocumented event name supplied through `hermesagent.hooks`, is still retracted when it disappears from the source.\n\nHooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Qwen Code, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`) — both share one event surface apart from `notification` (see below), in which `preToolUse`/`postToolUse` become named `tool.execute.before`/`tool.execute.after` hooks, `preCompact` becomes the named `experimental.session.compacting` hook, `beforeSubmitPrompt` the named `chat.message` hook and `preModelInvocation` the named `chat.params` hook (fired before every LLM request, for subagents too) — all three receive `(input, output)` and expose nothing to match on, so a `matcher` on any of them is dropped — while `userPromptExpansion` becomes the named `command.execute.before` hook, which fires only for slash commands (on the expanded prompt, right before it is submitted) and tests a `matcher` against the command name (`input.command`), `beforeShellExecution`/`afterShellExecution` also land in those named `tool.execute.*` hooks with an implicit `input.tool === \"bash\"` gate — OpenCode has no shell-execution lifecycle event (`command.executed`, which earlier Rulesync versions mapped `afterShellExecution` to, is a _slash-command_ event, so the hook never fired on shell commands; regenerate to fix), and matchers on the shell events are dropped with a warning since the named hooks expose no command text, and the rest are `event.type` dispatches — `sessionStart` → `session.created`, `stop` → `session.idle`, `afterFileEdit` → `file.edited`, `permissionRequest` → `permission.asked`, `permissionDenied` → `permission.replied` (which fires for every reply, so the generated handler is gated on `event.properties.reply === \"reject\"`), `notification` → `tui.toast.show` (**OpenCode only** — Kilo's plugin docs document no TUI events, so `notification` is not part of its surface; note too that OpenCode's toast channel is broader than the canonical event, since every `info`/`success`/`warning`/`error` toast fires the hook rather than only the ones asking for your attention, and most toasts originate in the TUI client, so a headless `opencode run` rarely fires it at all), `postCompact` → `session.compacted`, `afterError` → `session.error`, `fileChanged` → `file.watcher.updated`, `sessionDelete` → `session.deleted`; Amp hooks are emitted as a TypeScript plugin (`.amp/plugins/rulesync-hooks.ts`, or `~/.config/amp/plugins/rulesync-hooks.ts` in global mode) using `session.start`, `tool.call`, `tool.result`, `agent.start`, and `agent.end` (plus the Amp-only `changes.prompt` from the `amp.hooks` override block); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_before_settle` (added in Pi v0.87.0, so it needs Pi v0.87.0 or later — older Pi never fires it and stop hooks silently do not run there), `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name, `preCompact` → `session_before_compact`, `postCompact` → `session_compact`, `postModelInvocation` → `message_end` gated on assistant messages so it runs once per finalized model response, `beforeSubmitPrompt` → `input`, `postToolUse` → `tool_result`, `postToolUseFailure` → the same `tool_result` gated on `event.isError` — Pi fires one event for every finished tool call and flags the failed ones, so the two canonical events share one subscription and only the failure handlers check the flag; `postToolUse` keeps firing for failed calls too, as it always has on Pi — and `notification` → `ui_prompt_start` (added in Pi v0.84.4), Pi's \"waiting for user\" signal: it fires when an extension opens a blocking `ctx.ui.select` / `confirm` / `input` / `editor` / `custom` prompt, so it covers prompts raised by extensions — Rulesync's own or third-party ones — and never core Pi, which has no built-in permission popup; it is notification-only (handlers are best-effort and not awaited, so the hook command can neither delay nor cancel the prompt), nested prompts coalesce into one span so it fires once per span, and a `matcher` on it is dropped because the handler tests nothing against it) — `tool_call` is Pi's only tool gate, so a `preToolUse` command that exits non-zero denies the call with `{ block: true, reason }` (the reason is the command's stderr, falling back to its stdout and then to its exit code, with terminal escape sequences, control and C1 characters, and format characters such as bidirectional overrides and zero-width joiners stripped, carriage returns folded into newlines so the text cannot overwrite an already printed line, the sanitized result capped at 2000 characters (and only the first 128000 characters of the command's output scanned at all) — sanitizing first so a reason that opens with a progress banner is still reported by its content — and a generic `Hook command failed.` used when nothing survives), and `input` is its prompt-submission gate, so a `beforeSubmitPrompt` command that exits non-zero cancels the prompt with `{ action: \"handled\" }` — that result carries no reason field, so the same text is reported through `ctx.ui.notify`, falling back to stderr in print (`-p`) and JSON modes where that channel is a no-op; three limits are worth knowing before relying on the prompt gate: Pi checks extension slash commands before the `input` event, so input consumed as a `/cmd` never reaches the gate, messages another extension injects via `sendUserMessage` (`event.source === \"extension\"`) are deliberately passed through because the canonical event covers prompts the user submits, and the hook command receives no prompt text (Pi's `input` event exposes it, but the generated handler runs the command with no stdin or arguments, unlike Claude Code's `UserPromptSubmit`), so the gate can only decide from ambient state; a `matcher` on `beforeSubmitPrompt` is dropped, since `input` exposes no tool name to test it against; `agent_before_settle` is Pi's last actionable boundary before a run settles (it fires after automatic retries and compaction, so a stop hook runs once per settle, unlike the notify-only `agent_end` earlier Rulesync versions mapped `stop` to), so a `stop` command that exits with code 2 asks the agent to keep going like a blocking Claude Code `Stop` hook (any other non-zero exit, including a command that cannot be run, never continues the agent, as Claude Code treats it as a non-blocking error; the handler rethrows it after the remaining stop commands run, so Pi reports it as an extension error): every configured stop command runs, and the same sanitized text from each command that exited with 2 is combined into one displayed `custom_message` (`Stop hook feedback: …`) appended after any entries other extensions proposed, with `continue: true` for one more model request — note that this sends the command's output to the model, so do not print secrets from a stop hook; to avoid the loop Pi warns about, this happens at most once per run (the guard resets on `agent_settled`, so the next prompt may continue again; later failures in the same run are only reported), never for a run that was aborted or ended in an error, and the hook command receives no event payload, so there is no equivalent of Claude Code's `stop_hook_active` to check; every other event, `postToolUse`/`tool_result` included, observes only and cannot block or mutate Pi events; a denied call deliberately leaves Pi's `terminate` flag unset so control returns to the model instead of ending the turn; Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and write the command into the `bash`/`powershell` field named by the canonical `shell` selector, or into the portable `command` field when none is set — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli gets the Hooks v2 PascalCase `HookEvent` names (e.g. `SessionStart`, `PostToolUseFailure`) in a `{ \"hooks\": { \"<Event>\": [{ \"matcher\": …, \"hooks\": [{ \"type\": \"command\", … }] }] } }` document — this requires deepagents-code 0.1.52+, the release where Hooks v2 became generally available (the legacy flat list is removed upstream on 2026-09-01; Rulesync still imports the legacy format but no longer writes it); `kiro-cli` and `kiro-ide` emit hooks into the standalone `.kiro/hooks/rulesync.json` with PascalCase triggers, while the deprecated `kiro` alias still writes them into `.kiro/agents/default.json` using the older event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's); ZCode emits PascalCase events into the `events` map nested under the `hooks` key of `.zcode/config.json` (project, the same file that holds its MCP servers, whose `mcp` key is preserved) or `~/.zcode/cli/config.json` (global, via `--global`) — ZCode runs workspace hooks only after each user trusts them (new or changed workspace hooks wait for approval in ZCode's review prompt or via `zcode hooks trust review` / `grant`, and headless runs skip untrusted ones with `workspace_hooks_pending_trust`), so any change to the generated hooks asks every teammate to trust them again; ZCode's hooks docs page still says workspace hooks are ignored, but the v3.8.1 changelog announces workspace-level hooks and the v3.14.3 runtime executes trusted ones — seven events in all, matcher-capable except on `UserPromptSubmit` and `Stop` (which expose no value to match against), with commands emitted verbatim — note that ZCode reads a matcher of only letters, digits, underscores and `|` as an exact name list (`Write|Edit` matches those two tool names) and anything else as a case-sensitive JavaScript regex, and Rulesync passes the value through unchanged; it states `hooks.enabled: true` when it writes events and the file states no `enabled` preference, because ZCode runs no configuration hooks while that switch is off (any authored `enabled` value, including a deliberate `false`, is left alone as the user's switch) — note that ZCode's master switch is on when any config source states `enabled: true`, so a committed project `.zcode/config.json` with `enabled: true` can also activate a teammate's user-level hooks whose `~/.zcode/cli/config.json` leaves `enabled` unset inside that project (a source that states `enabled: false` keeps its own hooks off); ZCode's native `process` hook type has no canonical equivalent, so those entries are skipped with a warning on import.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) hooks are emitted as the same kind of Rulesync-owned TypeScript extension as Pi's, written to `.omp/extensions/rulesync-hooks.ts` (project) / `~/.omp/agent/extensions/rulesync-hooks.ts` (global, via `--global`; default profile only, so a named profile or a `PI_CODING_AGENT_DIR` override is not followed). oh-my-pi loads extension modules from those directories itself; Rulesync does not use the legacy `.omp/hooks/pre|post/` hook directories, since one module subscribes to both pre- and post-tool events. The generated module imports its types from `@oh-my-pi/pi-coding-agent` and follows the Pi mapping above for `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `preModelInvocation`, `postModelInvocation`, `beforeSubmitPrompt`, `preCompact` and `postCompact`, with the same matcher, blocking and reason-sanitizing behavior, except that a failing `beforeSubmitPrompt` command cancels the prompt with oh-my-pi's `{ handled: true }` result instead of Pi's `{ action: \"handled\" }`. oh-my-pi has no `agent_before_settle`, so `stop` maps to its own `session_stop` event, which fires before a main-session run settles (not for subagents, an aborted run, a run whose last message still has tool calls, or a stop that another stop-time continuation such as compaction or a todo reminder pre-empts; it waits for background jobs to finish). As on Pi, only a stop command exiting with code 2 continues the agent, with the combined feedback of every such command as model-visible context that oh-my-pi does not display (`{ continue: true, additionalContext }`); any other failure is rethrown for oh-my-pi to report. The agent is continued at most once per stop: no continuation is requested while `stop_hook_active` is set (including after a continuation another extension requested), nor for a run that ended on a provider error. oh-my-pi gives `session_stop` handlers the same 30-second budget as other extension handlers: if the stop commands run longer, the handler times out, the run settles without a continuation, and the command is not killed. `notification` is not supported: oh-my-pi has no `ui_prompt_start`. oh-my-pi fails a `tool_call` closed when its handlers exceed `extensionHandlers.toolCallTimeoutMs` (30 seconds by default), so a slow `preToolUse` command blocks the tool call, and it gives `session_shutdown` handlers only 2 seconds. The prompt gate is narrower than the tool gate: oh-my-pi fires `input` only for prompts submitted interactively or over RPC (print mode and ACP input bypass it), and an `input` handler that exceeds the 30-second handler budget lets the prompt through. Tool-scoped hooks go under an `omp.hooks` block. See the [oh-my-pi extension docs](https://github.com/can1357/oh-my-pi/blob/main/docs/extensions.md) and [extension loading docs](https://github.com/can1357/oh-my-pi/blob/main/docs/extension-loading.md).\n\nExample:\n\n```json\n{\n \"version\": 1,\n \"hooks\": {\n \"sessionStart\": [{ \"type\": \"command\", \"command\": \".rulesync/hooks/session-start.sh\" }],\n \"preToolUse\": [{ \"matcher\": \"Bash\", \"command\": \".rulesync/hooks/confirm.sh\" }],\n \"postToolUse\": [{ \"matcher\": \"Write|Edit\", \"command\": \".rulesync/hooks/format.sh\" }],\n \"stop\": [{ \"command\": \".rulesync/hooks/audit.sh\" }]\n },\n \"cursor\": {\n \"hooks\": {\n \"afterFileEdit\": [{ \"command\": \".cursor/hooks/format.sh\" }]\n }\n },\n \"claudecode\": {\n \"hooks\": {\n \"notification\": [\n {\n \"matcher\": \"permission_prompt\",\n \"command\": \"$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh\"\n }\n ]\n }\n },\n \"opencode\": {\n \"hooks\": {\n \"afterShellExecution\": [{ \"command\": \".rulesync/hooks/post-shell.sh\" }]\n }\n },\n \"copilot\": {\n \"hooks\": {\n \"afterError\": [{ \"command\": \".rulesync/hooks/report-error.sh\" }]\n }\n }\n}\n```\n\n**Top-level keys:**\n\n- `version`: Schema version (currently `1`).\n- `hooks`: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event.\n- `amp.hooks`, `cursor.hooks`, `claudecode.hooks`, `opencode.hooks`, `mimocode.hooks`, `kilo.hooks`, `copilot.hooks`, `copilotcli.hooks`, `factorydroid.hooks`, `codexcli.hooks`, `goose.hooks`, `deepagents.hooks`, `kiro.hooks`, `qwencode.hooks`, `grokcli.hooks`, `zcode.hooks`, `bob.hooks`, `tabnine.hooks`, `cortexcode.hooks`, `codewhale.hooks`, `continue.hooks`, `commandcode.hooks`, `lettacode.hooks`, `qoder.hooks`, `gitlabduo.hooks`, `crush.hooks`, `pool.hooks`: Tool-specific **override keys**. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. `afterFileEdit` for Cursor/OpenCode/Kilo, `worktreeCreate` for Claude Code, `afterError` for Copilot/Copilot CLI, `PostFileSave`/`PreTaskExec` for Kiro) can coexist with shared ones without leaking to other tools. `copilotcli.hooks` falls back to `copilot.hooks`, which in turn falls back to the shared `hooks` block.\n\nBy default the generated hooks replace the destination's hook list wholesale, so a handler another tool or a person added by hand there is lost on the next `generate`. Setting `\"preserveUnownedHooks\": true` in `rulesync.jsonc` keeps those handlers and appends the generated ones instead; see [Configuration](../guide/configuration.md). It covers Claude Code, Codex CLI and Cursor (the Claude Code plugin bundle always replaces) and records what Rulesync generated in a `.rulesync-hooks-lock.json` next to each hooks file, so a hook Rulesync wrote and the sources no longer define is still retracted.\n\n**Hook entry keys:**\n\n- `command` (required): Shell command to execute when the event fires.\n\n Write paths to project files as `./`-relative paths. For targets that anchor hook commands to a project-directory variable (Claude Code's `$CLAUDE_PROJECT_DIR`, the Claude Code plugin's `$CLAUDE_PLUGIN_ROOT`, the AugmentCode plugin's `$AUGMENT_PLUGIN_ROOT`, Command Code, Continue, Cortex Code and Factory Droid), Rulesync prefixes a command whose first word starts with `.` (but not the `.` builtin, as in `. ./env.sh`) with the variable. It also anchors later `./` words that name a file the command runs:\n\n - Anchored positions: the command word of each later command in a chain or pipeline, or inside `$( … )`, backticks or a single-quoted `-c` script of `sh`, `bash` and similar shells (combined flags such as `bash -lc '…'` included; the script's own quotes are respected); and the first non-option argument of a script runner — `.`, `source`, `sh`, `bash` (and similar shells), `node`, `bun`, `tsx`, `ts-node`, `python` (also `python3` or `python3.12`), `ruby`, `perl` or `php`, as in `node ./x.js` or `python3 \"./my x.py\"`. The same applies behind a package runner or run subcommand (`npx tsx ./x.ts`, `pnpm exec tsx ./x.ts`, `uv run ./x.py`, `uv run python ./x.py`, `bun run ./x.ts`, `deno run -A ./x.ts`), and to the value of a runner option that loads a file (`node -r ./register.js ./x.js`, `perl -I ./lib ./x.pl`). Prefix words and their options before the command word are skipped (`time -p node ./x.js`, `exec -a name ./x.sh`), as are `env` and the assignments after it (`env FOO=1 ./x.sh`), although nothing is anchored after an `env` option (`env -u NAME ./x.sh`, `env -C dir ./x.sh`); and an escaped or quoted command name (`\\node`, `\\bash -c '…'`, `\"bash\" -c '…'`) counts as the command itself; an option that takes a value, such as `python3 -W ignore ./x.py`, is skipped together with its value, and after inline code, a module, standard input or a package runner's shell mode (`python3 -c`, `python3 -m`, `node -e`, `bash -s`, `npx -c`, `pnpm exec --shell-mode`) nothing more is anchored; `yarn exec`, which runs its arguments through Yarn's own shell, is not followed either.\n - Written forms: an unquoted `./x` becomes `\"$CLAUDE_PROJECT_DIR\"/x`, a double-quoted `\"./x\"` becomes `\"$CLAUDE_PROJECT_DIR/x\"`, and a single-quoted `'./x'` becomes `\"$CLAUDE_PROJECT_DIR\"/'x'`.\n - Left as written: every other argument, whose meaning depends on the command (`npx prettier --write ./src`, a redirection target, `--config=./c.json`, the arguments of a command run through `npx` that is not a script runner, such as `npx prettier`), and anything handed to a container or another host (`docker`, `podman`, `nerdctl`, `kubectl`, `ssh`, `vagrant`). Text inside a quoted string, comments, `$'…'` strings and `../` or `.name` words are never anchored, and neither is an unquoted or double-quoted `sh -c` script, which the inner shell would split again once expanded, nor a `-c` script handed to another command (`sudo sh -c '…'`, also written `\\sudo`, or `find . -execdir sh -c '…' \\;`), which runs elsewhere if at all. A quote on the line after `sh -c` starts a new command rather than the script, unless a line continuation (`\\` at the end of the line) joins the lines.\n\n These later words are scanned as POSIX shell syntax, so a hook with `shell: \"powershell\"` only has its first word prefixed, on generate and on import alike. This applies only to targets whose hooks have a `shell` field (Claude Code, including its plugin hooks); every other target runs the hook in its own shell, so `shell` does not change what is anchored there.\n\n Import converts the variable back to `./` wherever it starts a path, so a hook such as `python3 \"$CLAUDE_PROJECT_DIR/hooks/check.py\"` becomes `python3 \"./hooks/check.py\"` and the variable does not leak into targets that never set it. It keeps the variable after the first word in a command that changes directory (`cd`, `pushd`, `popd`), uses `eval`, a heredoc or here-string (`<<`), or one of the container and remote commands above, since `./` would no longer mean the same place there; a command that starts with another variable (such as `$HOME/…`) is converted in its first word only as well, a `-c` script handed to another command (`sudo sh -c '…'`) is left as it is, and so is the value of an assignment word or option (`PATH=$CLAUDE_PROJECT_DIR/bin:$PATH`, `--config=$CLAUDE_PROJECT_DIR/c.json`), which generate never anchors; import prints a warning naming a command that keeps the variable this way. A variable in an argument that generate does not anchor (`npx prettier --write \"$CLAUDE_PROJECT_DIR\"/src`) comes back as `./src`, which resolves against the directory the hook runs in, and import prints a warning naming the command. Claude Code runs hooks in its current directory, which is a worktree or a directory Claude changed into rather than always the project root, so to keep the variable there, write the command in the `claudecode.hooks` override. A working directory changed by other means (for example `git -C dir`, `env -C dir`, `uv run --directory dir`, `pnpm -r exec`, `pnpm --filter=pkg exec` or `bun --cwd=dir run`) or by an obfuscated `cd` (such as `c\\d`) is not detected, and neither is a path in a `-c` script that follows a single quote embedded as `'\\''`.\n\n- `type` (optional): One of `\"command\"` (default), `\"prompt\"`, `\"http\"`, `\"agent\"`, `\"mcp_tool\"`, or `\"function\"` — the union of the hook types accepted across supported tools. Each tool supports a subset (most support only `command`); hooks with a type a tool does not support are skipped for that tool with a warning. See notes below.\n- `matcher` (optional): Regex used by tools that scope hooks to specific tool names (e.g. `preToolUse`, `postToolUse`, `notification`). Ignored by events that do not take a matcher (e.g. `sessionStart`, `worktreeCreate`, `worktreeRemove`).\n- `timeout` (optional): Per-hook timeout in seconds, forwarded to tools that support it.\n- `cacheTtl` (optional): Number of seconds to cache a successful hook result. Forwarded to the deprecated `kiro` alias's agent-config format as `cache_ttl_seconds`; `0` disables caching and Kiro never caches `AgentSpawn` hooks.\n- `failClosed` (optional): Boolean. When `true`, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's `.cursor/hooks.json`, to JetBrains Junie's `~/.junie/config.json` (as Junie's equivalently-named `blockOnError` flag), to Hermes Agent's `~/.hermes/config.yaml` (as `fail_closed`), and to Goose's plugin `hooks.json` (as `on_failure: \"block\"`, Goose v1.48.0+). Hermes only honours it on `pre_tool_call` (its one blocking-capable event) and Goose only reads `on_failure` on `PreToolUse`, so a `failClosed` set on any other canonical event is dropped with a warning for those targets.\n- `commandRegex` (optional): Regex applied to the shell command string, narrowing an `Execute` matcher group further (e.g. `\"^git \"`). Forwarded to Factory Droid, which skips invalid regex values. Like `matcher`, it belongs to the whole matcher group, so every hook sharing that matcher receives it.\n- `async` (optional): Boolean. When `true`, the hook command runs in the background without blocking. Forwarded to Qwen Code (`.qwen/settings.json`), JetBrains Junie (`~/.junie/config.json`, same field name), Claude Code and Codex CLI (`.codex/hooks.json`, same field name — Codex runs up to eight background hooks concurrently per session and queues the rest).\n- `env` (optional, `command` hooks): a map of extra environment variables merged into the hook process's environment. Forwarded to Qwen Code (`.qwen/settings.json`), Copilot CLI, and Grok CLI (`.grok/hooks/rulesync.json`, upstream `HookConfig.env`, merged into the spawned command's `extra_env`). Documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning). For Grok CLI, an entry whose key is empty or contains `=`, or whose key or value contains a newline, carriage return or NUL, is refused in both directions — the tool rebuilds each entry into a `KEY=VALUE` string, so such a key would name a different variable than it appears to.\n- `shell` (optional): Either `\"bash\"` or `\"powershell\"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code, Claude Code, Copilot and Copilot CLI command hooks; for the two Copilot targets it names the `bash`/`powershell` field the command is written into, and leaving it unset selects their portable `command` field. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning).\n- `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.\n- `server` / `tool` / `input` (optional, `mcp_tool` hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support `${path}` substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.\n- `model` (optional, `prompt` / `agent` hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks, to Qwen Code prompt hooks, and to Cursor prompt hooks (the [Cursor hooks docs](https://cursor.com/docs/hooks) document it as an optional prompt-hook override; it is dropped from Cursor `command` hooks).\n- `args` (optional, `command` hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns `command` directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder `${CLAUDE_PROJECT_DIR}/…` that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code, AugmentCode and Copilot CLI (where it is written as the CLI-only `exec` + `args` pair). Only `command` is prefixed; entries of `args` are passed through exactly as written.\n- `asyncRewake` (optional): boolean. Like `async`, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks.\n- `once` (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks.\n- `continueOnBlock` (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code.\n- `commandWindows` (optional): a Windows-only override for `command`, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.\n- `additionalContextLimit` (optional): a non-negative integer. The token threshold above which the tool writes the hook's additional context to a file and passes that path instead of the text itself (upstream default 2500). Forwarded to Codex CLI command hooks (`.codex/hooks.json`), which is the only tool that accepts it.\n- `statusMessage` (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.\n- `enabled` (optional): boolean, default `true`. Whether the hook is active. Forwarded to Kiro's standalone hooks file (`.kiro/hooks/rulesync.json`, written by the `kiro-cli` and `kiro-ide` targets) and to ZCode's per-hook `enabled` in `.zcode/config.json` / `~/.zcode/cli/config.json` and in a ZCode plugin's `hooks/hooks.json` — the places with a per-hook on-disk enable flag Rulesync writes; an imported `enabled: false` round-trips, so a deliberately disabled hook is not silently switched back on by the next generate. Every other target has no way to express it, so the hook is emitted there as an ordinary **active** hook and a warning is logged at generate time — to turn a hook off everywhere, remove it rather than setting `enabled: false`. (Antigravity has an `enabled` flag of its own, but on the named hook group rather than the individual definition, so it is not driven by this field.)\n- `confirm` (optional): `{ \"question\": \"…\", \"options\": [{ \"id\": \"…\", \"label\": \"…\", \"run\": true | false }], \"confirmCommand\": \"…\" }` — a confirmation prompt shown before the hook runs, where the chosen option's `run` decides whether the command executes. The optional `confirmCommand` inside the block names a command whose JSON stdout drives that prompt dynamically (`{ \"skip\": true }` suppresses it and skips the hook; a replacement `question`/`options` object overrides the static prompt; on failure or timeout the static prompt is used). Forwarded verbatim to Kiro's standalone hooks file (`kiro-cli` and `kiro-ide`), which honors it on `Stop`-trigger command hooks, and round-tripped on import so a hand-written prompt survives regeneration. No other target has an equivalent: they drop the block with a warning and run the hook unconditionally.\n- `if` (optional): a single permission rule (same syntax as `settings.json` permission rules, e.g. `\"Bash(rm *)\"`) that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (`preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `permissionDenied`); it round-trips as an opaque string.\n- `source` (optional, `command` hooks): an object `{ \"source\": \"github:<org>/<repo>/<path>\", \"ref\"?: \"<branch or commit>\" }` naming a script fetched from a Git repository (a Snowflake Cortex Code \"remote hook\"); `command` then names the interpreter the fetched script runs under, e.g. `\"bash\"`. Forwarded to Snowflake Cortex Code, which is the only tool that accepts it; on import an object without a `source` key is dropped with a warning.\n\nA field a tool documents on `command` hooks only (`args`, `env`, `shell`, `async`, `asyncRewake`, `source`) is dropped with a warning in both directions when it appears on a hook of another type — on generate for a value authored in `.rulesync/hooks.*`, and on import for one found in an existing tool config. Import additionally checks each value against the constraint the canonical field declares (e.g. `shell` must be `\"bash\"` or `\"powershell\"`, `additionalContextLimit` must be a non-negative integer, and no string field may carry a newline, carriage return or NUL); a value that fails is skipped with a warning naming the constraint, rather than imported into a file the next generate would refuse to read. When the offending value is a `command`, `prompt` or `matcher`, the whole hook is skipped instead of just that field, since a hook without its command runs nothing and a hook without its matcher fires on everything.\n\nTop-level `hooks` keys must be canonical event names; unknown event names are rejected at parse time. Tool-specific override blocks (e.g. `kiro.hooks`) additionally accept tool-native event keys, which pass through verbatim.\n\nEvents present in the shared `hooks` block but unsupported by a given tool are skipped for that tool (a warning is logged at generate time). The canonical `notification` event maps to deepagents-cli's Hooks v2 `Notification` event, whose matcher selects the notification kind (e.g. `agent_needs_input`); canonical `contextOffload` is skipped for deepagents-cli, since its legacy `context.offload` event has no Hooks v2 counterpart.\n\n### Hook event × tool matrix\n\n<!-- HOOK_EVENTS_MATRIX:BEGIN -->\n\n| Event | Amp | IBM Bob | Claude Code | Claude Code plugin | CodeBuddy Code | Codewhale | Codex CLI | Command Code | GitHub Copilot | GitHub Copilot CLI | Crush | Goose | Hermes Agent | Grok CLI | Cursor | deepagents-cli | Factory Droid | OpenCode | MiMo Code | GitLab Duo CLI | Cline | Kilo Code | Kimi Code | Tabnine CLI ⚠️ | Continue ⚠️ | Snowflake Cortex Code | Vibe Code | Vibe Code plugin | Qoder | Qwen Code | Reasonix | Kiro ⚠️ | Kiro CLI | Kiro IDE | Letta Code | Google Antigravity IDE | Google Antigravity CLI | Google Antigravity plugin | JetBrains Junie | AugmentCode | AugmentCode plugin | Devin Desktop | Devin plugin | Pi Coding Agent | oh-my-pi | ZCode (Z.ai) | ZCode plugin | Pool (Poolside) |\n| ---------------------- | :-: | :-----: | :---------: | :----------------: | :------------: | :-------: | :-------: | :----------: | :------------: | :----------------: | :---: | :---: | :----------: | :------: | :----: | :------------: | :-----------: | :------: | :-------: | :------------: | :---: | :-------: | :-------: | :------------: | :---------: | :-------------------: | :-------: | :--------------: | :---: | :-------: | :------: | :-----: | :------: | :------: | :--------: | :--------------------: | :--------------------: | :-----------------------: | :-------------: | :---------: | :----------------: | :-----------: | :----------: | :-------------: | :------: | :----------: | :----------: | :-------------: |\n| `sessionStart` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `sessionEnd` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — |\n| `preToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `postToolUse` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — |\n| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — |\n| `beforeSubmitPrompt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `stop` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `subagentStop` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `preCompact` | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ |\n| `postCompact` | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — |\n| `postToolUseFailure` | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — |\n| `subagentStart` | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeShellExecution` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterShellExecution` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeMCPExecution` | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterMCPExecution` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeReadFile` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterFileEdit` | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterAgentResponse` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterAgentThought` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeTabFileRead` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterTabFileEdit` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `permissionRequest` | — | — | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | — | — | — | — | — | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | ✅ | — | — | ✅ | ✅ | — | — | ✅ | ✅ | — |\n| `notification` | — | — | ✅ | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | — | — | ✅ | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — |\n| `setup` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `afterError` | — | — | — | — | — | ✅ | — | — | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `beforeToolSelection` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `worktreeCreate` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `worktreeRemove` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `workspaceOpen` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `messageDisplay` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `todoCreated` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `todoCompleted` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `stopFailure` | — | — | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — |\n| `stopCancelled` | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `instructionsLoaded` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `userPromptExpansion` | — | — | ✅ | ✅ | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `postToolBatch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `permissionDenied` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `taskCreated` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `taskCompleted` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `teammateIdle` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `configChange` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `cwdChanged` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `fileChanged` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `directoryAdded` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `elicitation` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `elicitationResult` | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `sessionDelete` | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `preModelSwitch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n| `postModelSwitch` | — | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |\n\n<!-- HOOK_EVENTS_MATRIX:END -->\n\n> **Note:** `beforeSubmitPrompt`, `stop`, `worktreeCreate`, `worktreeRemove`, `messageDisplay`, `postToolBatch`, `taskCreated`, `taskCompleted`, `teammateIdle`, and `cwdChanged` are the Claude Code events the [matcher table](https://code.claude.com/docs/en/hooks) lists as not supporting the `matcher` field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into `settings.json` to be ignored. `directoryAdded` is **not** one of them: the matcher table documents it as filtering on how the directory was added (`slash_command`, `register_repo_root`), so a matcher written on it is emitted as-is. The same holds for `preModelSwitch` and `postModelSwitch` (Claude Code v2.1.251), whose matcher is tested against the canonical name of the model the session switches to (e.g. `.*opus.*`); `preModelSwitch` can block the switch.\n\n> **Note:** Rulesync implements OpenCode hooks as a plugin at `.opencode/plugins/rulesync-hooks.js` and Kilo hooks as a plugin at `.kilo/plugins/rulesync-hooks.js`, so importing from OpenCode/Kilo to rulesync is not supported. Both only support command-type hooks (not prompt-type).\n\n> **MiMo Code note:** MiMo Code loads the same OpenCode plugin API from `.mimocode/plugins/` (global: `~/.config/mimocode/plugins/`), so Rulesync writes the same plugin to `.mimocode/plugins/rulesync-hooks.js` with the OpenCode event mapping. Entries under `mimocode.hooks` are emitted only for MiMo Code. Import is not supported, as for OpenCode.\n\n> **Note:** Rulesync implements Amp hooks as a generated TypeScript plugin at `.amp/plugins/rulesync-hooks.ts` (project) or `~/.config/amp/plugins/rulesync-hooks.ts` (global), so importing arbitrary Amp plugin code is not supported. Amp supports command hooks for `sessionStart` → `session.start`, `preToolUse` → `tool.call`, `postToolUse` → `tool.result`, `beforeSubmitPrompt` → `agent.start`, and `stop` → `agent.end`. Tool-event matchers are regular expressions against the Amp tool name; definitions with a matcher on any lifecycle event are skipped with a warning. A failing `preToolUse` command rejects the tool call and lets the agent continue; other mapped events observe the command result. Amp's `changes.prompt` event (fired while Amp prepares the prompt behind its Ship or Push to Branch button) has no canonical equivalent, so it is accepted only under the `amp.hooks` override block, keyed by its native name (for example `\"amp\": { \"hooks\": { \"changes.prompt\": [{ \"command\": \"./ship-notes.sh\" }] } }`): the trimmed stdout of each command that exits with code 0 is appended to that prompt (multiple outputs are separated by a blank line), a failing command contributes nothing and cannot block the prompt, and a `matcher` on it is skipped with a warning.\n\n> **Amp command syntax:** Amp executes plugin commands with [Bun Shell](https://bun.com/docs/runtime/shell), whose syntax differs slightly from POSIX shells. Use `$VAR` for environment expansion (`${VAR}` remains literal) and `$(command)` for command substitution (backticks remain literal). Rulesync passes the authored command through unchanged so quoting and escaped operators retain their Bun Shell meaning.\n\n> **Note:** GitHub Copilot's format uses separate `powershell` and `bash` fields for hooks, plus a portable `command` field that upstream copies into both when neither is present. Rulesync picks between them with the canonical `shell` selector, and writes the portable `command` field when a hook does not set one. Earlier versions chose the field from the platform Rulesync happened to run on; regenerate to get a machine-independent file. VS Code reads the same file and maps `bash` to its `osx` / `linux` and `powershell` to its `windows` overrides; its own per-OS keys `windows`, `linux` and `osx` (see the [VS Code hooks reference](https://code.visualstudio.com/docs/agents/reference/hooks-reference)) can be authored on a canonical hook and are passed through verbatim on generate and preserved on import, including for an entry that carries only those and no `command` — such an entry lands in the shared `hooks` block without a canonical `command`, so every other target would generate it command-less; the import warns, and adding a `command` fixes it. VS Code also documents that it ignores `matcher` values, so a hook with a `matcher` is dropped for this target rather than emitted unscoped.\n\n> **Note:** Hook file paths per tool:\n>\n> - **Copilot (cloud agent / VS Code)** — project: `<project>/.github/hooks/copilot-hooks.json`; global: `~/.copilot/hooks/copilot-ide-hooks.json`. Command hooks carry `bash`/`powershell` with optional `timeoutSec`, plus the canonical `env` map and a pass-through `cwd`. On import, `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. Which command field is written is chosen by the canonical `shell` selector; without it the portable `command` field is written, which upstream copies to both. It is deliberately **not** chosen from the platform Rulesync runs on: the cloud agent runs hooks in a **Linux sandbox** where only `bash` and `command` are honored, so a `powershell` entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to `bash` (with a warning) on every platform. VS Code and the coding agent both document `~/.copilot/hooks` as the user scope and load every `*.json` in that folder; the Copilot CLI's global file already occupies `copilot-hooks.json` there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of \"every `*.json` is loaded\": generating **both** `copilot` and `copilotcli` in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.\n> - **Copilot CLI** — project: `<project>/.github/hooks/copilotcli-hooks.json`; global: `~/.copilot/hooks/copilot-hooks.json`. The Copilot CLI docs let you choose any filename inside `.github/hooks/`, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global directory is the documented user-level hooks location, which loads every `*.json` inside it, so only the filename is Rulesync's choice. Copilot CLI uses a **wider event surface** than the shared cloud-agent set (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `agentStop` ← `stop`, `subagentStart`, `subagentStop`, `errorOccurred` ← `afterError`, `preCompact`, `permissionRequest`, `notification`, `userPromptTransformed` ← `userPromptExpansion`, `preMcpToolCall` ← `beforeMCPExecution`) and supports three hook types: **`command`** (`bash`/`powershell` with optional `timeoutSec`, plus pass-through `cwd`/`env`; on import the portable `command` field is read as the cross-platform fallback when neither shell field is present, and `timeout` is honored as an alias for `timeoutSec` when `timeoutSec` is absent. On generate the canonical `shell` selector chooses `bash` or `powershell`; without it the portable `command` field is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves to `bash` (with a warning) on every platform, so importing the same file yields the same canonical config everywhere. The CLI-only shell-free form — `exec` naming an executable plus an `args` list, spawned without a shell — is the canonical `command` + `args` exec form: a canonical `args` list (an empty one counts) makes Rulesync write `exec`/`args` instead of a shell field, dropping any `shell` selector with a warning, and an imported `exec` entry lands as `command` + `args` (an empty list when upstream omitted `args`) rather than as a hook with no command; an `exec` combined with `bash`/`powershell`/`command`, which the hooks reference says not to do, imports as the `exec` with a warning), **`prompt`** (a `prompt` string — Copilot CLI only honors prompt hooks on `sessionStart`, so prompt hooks on other events are dropped), and **`http`** (`url`/`headers`/`allowedEnvVars` with optional `timeoutSec`). An entry's optional `matcher` field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — `preToolUse` and `postToolUse` (regex on the tool name), `permissionRequest` (tool name), `notification` (notification type), `preCompact` (the trigger, `manual` or `auto`) and `subagentStart` (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. The CLI's `disableAllHooks` switch (disable every hook, repository- and user-level) lives in its settings file rather than in a hooks file, so it is authored as `copilotcli.disableAllHooks` in `.rulesync/hooks.jsonc` and written to `.github/copilot/settings.json` (project) / `~/.copilot/settings.json` (global). Only that key is patched, and only when it is authored, so a value set by hand survives an unrelated regeneration and other settings (`deniedUrls`, `model`, ...) are preserved; `false` is written as is, which at project scope overrides a user-level `true` because the repository value takes precedence. Import reads it back into the same override. See the [hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference) and the [CLI config dir reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference).\n> - **Antigravity IDE / Antigravity CLI** — project: `<project>/.agents/hooks.json`; global: `~/.gemini/config/hooks.json`. Both targets share the same dedicated `hooks.json` (a Claude-Code-style matcher map nested under a generated `rulesync` hook name), so enabling both writes the same file.\n> - **Devin Desktop (formerly Windsurf)** — project: `<project>/.windsurf/hooks.json`; global: `~/.codeium/windsurf/hooks.json`. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand.\n> - **Factory Droid** — project: `<project>/.factory/hooks.json`; global: `~/.factory/hooks.json`. A standalone `hooks.json` is keyed **directly by event name** (`{\"PreToolUse\": [...]}`); the `hooks` wrapper Droid documents belongs to `settings.json` only, so Rulesync writes the bare event map. The file is Rulesync-owned and rewritten wholesale, which also repairs a `hooks.json` an earlier version left in the wrapped shape — Droid found no known event key at the top level of that file, so none of those hooks ever fired; regenerate to fix. Import accepts both shapes: the top level when it names an event, and the `hooks` key otherwise, which is also how the legacy `.factory/settings.json` read-time fallback is understood. Import falls back in order: `.factory/hooks.json`, then the `.factory/settings.json` `hooks` key (with `.factory/settings.local.json` overlaid, since Droid reads the pair as one) when those settings actually carry a `hooks` key — an unrelated `settings.local.json` therefore does not shadow the next step — then the pre-1.0 `.factory/hooks/hooks.json` — last because Droid renames that file to `hooks.migrated.json` once it has migrated it, so a copy still sitting there is the least likely to be live. Only `.factory/hooks.json` is ever written. As with permissions below, a hook that came from the machine-local file is imported into `.rulesync/hooks.json` like any other, so the next `generate` writes it into the committed hooks files of every targeted tool — and a hook carries a command someone else's machine would then run. Drop a personal hook from `.rulesync/hooks.json` after importing if it should stay personal. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.\n> - **AugmentCode** — project: `<project>/.augment/settings.json`; global: `~/.augment/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file (which also holds `toolPermissions`).\n> - **IBM Bob** — project: `<project>/.bob/settings.json`; global: `~/.bob/settings/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file; the sibling keys Bob keeps there are left untouched.\n> - **Tabnine CLI** — project: `<project>/.tabnine/agent/settings.json`; global: `~/.tabnine/agent/settings.json`. Hooks are merged under the top-level `hooks` key of the shared settings file; the sibling keys Tabnine keeps there (`mcpServers`, `tools`, `hooksConfig`, ...) are left untouched.\n> - **Snowflake Cortex Code** — project: `<project>/.cortex/settings.json`; global: `~/.snowflake/cortex/hooks.json`. Hooks are merged under the top-level `hooks` key of either file; the sibling keys Cortex Code keeps there are left untouched, and neither file is removed by `--delete`. The per-hook `enabled` flag and the Remote Hooks `source` object (`{ \"source\": \"github:org/repo/path\", \"ref\": \"main\" }`, command hooks only) round-trip as-is.\n> - **Codewhale** — project: `<project>/.codewhale/hooks.toml`; global: the `[hooks]` table of `~/.codewhale/config.toml`. The project file holds top-level `[[hooks]]` entries and is owned by Rulesync; in the global config only the `[[hooks.hooks]]` entry list is replaced, while the table's own settings (`enabled`, `default_timeout_secs`, `working_dir`) and every other Codewhale setting are left untouched, and the file is never deleted.\n> - **Continue** — project: `<project>/.continue/settings.json`; global: `~/.continue/settings.json`. Hooks are merged under the top-level `hooks` key of the Continue CLI settings file; the sibling keys Continue keeps there are left untouched, and the file is never removed by `--delete`.\n> - **CodeBuddy Code** — project: `<project>/.codebuddy/settings.json`; global: `~/.codebuddy/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys CodeBuddy keeps there are left untouched, and the file is never removed by `--delete`. The gitignored `.codebuddy/settings.local.json` layer is left to CodeBuddy.\n> - **Command Code** — project: `<project>/.commandcode/settings.json`; global: `~/.commandcode/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys Command Code keeps there are left untouched, and the file is never removed by `--delete`.\n> - **Letta Code** — project: `<project>/.letta/settings.json`; global: `~/.letta/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions`; the sibling keys Letta Code keeps there (including the `hooks.disabled` switch) are left untouched, and the file is never removed by `--delete`.\n> - **Qoder** — project: `<project>/.qoder/settings.json`; global: `~/.qoder/settings.json`. Hooks are merged under the top-level `hooks` key of the settings file, next to `permissions` and (in the user file) `mcpServers`; the sibling keys Qoder keeps there are left untouched, and the file is never removed by `--delete`.\n> - **GitLab Duo CLI** — project: `<project>/.gitlab/duo/hooks.json`; global: `~/.gitlab/duo/hooks.json`. The file holds only `{ \"hooks\": { ... } }`, so Rulesync owns and rewrites it as a whole (and `--delete` removes it). Hooks are an experiment in GitLab Duo CLI 8.95.0 and later, and project-level hooks are disabled by default: start the CLI with `--enable-project-hooks` or set `GITLAB_ENABLE_PROJECT_HOOKS=true` to run them. Only `sessionStart` ⇄ `SessionStart` is documented, so every other event is skipped with a warning. Only `command` hooks are emitted; `matcher` (a regex on the session source, `startup` or `resume`) and `timeout` (seconds) round-trip. The CLI exports `DUO_PROJECT_DIR` to hook processes, so a `./`-relative or dot-relative command is anchored to `\"$DUO_PROJECT_DIR\"/`. See the [hooks docs](https://docs.gitlab.com/user/gitlab_duo_cli/customize/#hooks).\n> - **Crush** — project: `<project>/crush.json` (or `.crush.json` when that file already exists); global: `~/.config/crush/crush.json`. Hooks are written as the flat `hooks.PreToolUse` list of Crush's shared JSON config; the sibling keys Crush keeps there (`providers`, `options`, `mcp`, `permissions`, ...) are left untouched, and the file is never removed by `--delete`.\n> - **Pool** — project: `<project>/.poolside/settings.yaml`; global: `~/.config/poolside/settings.yaml`. Hooks are written as the per-event lists of the `hooks` block of Pool's shared YAML settings file; the sibling keys Pool keeps there (`pool`, `mcp_servers`, `tools`, `paths`, ...) and the non-event keys of the `hooks` block itself (`stop_hook_max_continuations`) are left untouched, and the file is never removed by `--delete`.\n> - **Kimi Code** — global only: `~/.kimi-code/config.toml`. Hooks are merged into the shared `[[hooks]]` array without replacing unrelated model, provider, or permission settings.\n> - **Vibe Code** — project: `<project>/.vibe/hooks.toml`; global: `~/.vibe/hooks.toml`. Stable since v2.21.0, which removed the `enable_experimental_hooks` flag: declaring a hook is enough, so Rulesync writes nothing into `.vibe/config.toml` for hooks.\n\n> **Note:** Claude Code hooks are merged under the `hooks` key of the shared `.claude/settings.json` (project) / `~/.claude/settings.json` (global). Whichever feature writes that file (hooks, permissions or ignore), Rulesync adds a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as the first entry when the file does not have one yet, so editors validate and autocomplete the settings. A `$schema` the file already states is left untouched, whatever its value.\n\n> **Note:** Because each AI tool evolves its own hook surface at its own pace, the matrix above reflects the events Rulesync currently translates. When a tool ships a new event that Rulesync does not yet support, the most reliable path is to open an issue — the matrix is the intended baseline to compare against.\n\n> **Note:** Kiro has two hook formats, and the target you pick decides which one you get. The **`kiro-cli`** and **`kiro-ide`** targets write the standalone `{ \"version\": \"v1\", \"hooks\": [ … ] }` file that both products read today — `.kiro/hooks/rulesync.json` in project scope and `~/.kiro/hooks/rulesync.json` in user scope — with one array entry per hook carrying `name`, `trigger`, an optional `matcher`, an `action` (`{ \"type\": \"command\", \"command\": … }` for a canonical `command` hook, `{ \"type\": \"agent\", \"prompt\": … }` for a `prompt` hook), an optional `timeout` in seconds, `enabled`, and — on `Stop`-trigger command hooks — the optional `confirm` prompt (with its nested `confirmCommand`). Triggers are PascalCase (`sessionStart` ⇄ `SessionStart`, `stop` ⇄ `Stop`, …); triggers with no canonical event, such as `PostFileSave` or `PreTaskExec`, are reachable through the **shared `kiro.hooks` override block** and pass through verbatim. Both targets write the same filename, so they read that one block rather than per-target `kiro-cli.hooks` / `kiro-ide.hooks` blocks — otherwise a divergent override would make the file's content depend on which target was generated last. Generating either or both targets therefore always yields the same file. A block authored under `kiro-cli.hooks` or `kiro-ide.hooks` is read by nothing and reported with a warning; move it to `kiro.hooks` (the same key the deprecated `kiro` alias, and the Kiro MCP and permissions wiring, already use). A second filename would not help either, since Kiro runs every `*.json` in the directory and both products read the same one. The deprecated `kiro` alias reads the same block but writes a different format, so the two writers each keep to their own vocabulary: a standalone-only trigger (`PostFileSave`, `PreTaskExec`, …) in `kiro.hooks` is dropped from the alias's `.kiro/agents/default.json` output with a warning, and an agent-config spelling (`agentSpawn`, `fileEdited`, …) is translated to its v1 equivalent (`SessionStart`, `PostFileSave`) for the standalone targets rather than written as a trigger Kiro does not define. Keys neither writer recognizes still pass through unchanged.\n>\n> The deprecated **`kiro`** alias still writes the older embedded format: `.kiro/agents/default.json` under the `hooks` field, merged with any existing agent configuration (tools, allowedTools, etc.). There, both `sessionEnd` and `stop` map to Kiro's `stop` event, only `command`-type hooks are supported (`prompt`-type hooks are silently skipped), per-hook timeouts are `timeout_ms` (milliseconds), and `cache_ttl_seconds` maps to the canonical `cacheTtl` field in both directions. Kiro's [hooks migration guide](https://kiro.dev/docs/cli/v3/hooks-migration/) states this format \"does not work in 3.0\", so prefer `kiro-cli`.\n>\n> If you generated `kiro-cli` hooks with an earlier Rulesync version, the `hooks` block it left in `.kiro/agents/default.json` is not removed for you — that file is shared with the permissions and subagents features, so Rulesync never deletes it. On Kiro CLI 2.x, which reads both formats, leaving it in place means every hook fires twice; delete the block by hand (or run Kiro's own agent migration) after regenerating. For the same reason, `rulesync import --targets kiro-cli --features hooks` now reads only the standalone file: to pull hooks out of an existing agent config, import with `--targets kiro`. Two event-surface differences come with the switch as well: Kiro's standalone triggers have no `SessionEnd`, so a canonical `sessionEnd` hook is dropped with a warning — use `stop` instead — and `cacheTtl` has no counterpart outside the agent-config format.\n\n> **Note:** Antigravity (IDE and CLI) writes a dedicated `hooks.json` keyed by a **named hook** whose value holds the event map, e.g. `{ \"rulesync\": { \"PreToolUse\": [ { \"matcher\": \"...\", \"hooks\": [...] } ], \"Stop\": [ { \"hooks\": [...] } ] } }`. Rulesync emits a single generated hook under the stable name `rulesync`. It supports five lifecycle events — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `preModelInvocation` ⇄ `PreInvocation`, `postModelInvocation` ⇄ `PostInvocation`, and `stop` ⇄ `Stop` — where `PreInvocation`/`PostInvocation`/`Stop` are matcher-less handler lists. On import, both the named-hook wrapper and a legacy flat top-level event map are accepted, and the optional per-hook `enabled` flag is ignored.\n\n> **Note:** Devin Desktop (formerly Windsurf) Cascade Hooks (GA) are written to a dedicated `hooks.json` whose top-level `hooks` key maps each Cascade event name to a **flat array** of hook objects (no `matcher`, no `type`, no inner `hooks` wrapper, and no `timeout`). Each object carries `command` and/or `powershell`, plus optional `show_output` and `working_directory`. Rulesync splits the generic tool lifecycle into Devin's file/command/MCP-specific events, so the canonical events map bijectively: `beforeReadFile` ⇄ `pre_read_code`, `beforeTabFileRead` ⇄ `post_read_code`, `afterTabFileEdit` ⇄ `pre_write_code`, `afterFileEdit` ⇄ `post_write_code`, `beforeShellExecution` ⇄ `pre_run_command`, `afterShellExecution` ⇄ `post_run_command`, `beforeMCPExecution` ⇄ `pre_mcp_tool_use`, `afterMCPExecution` ⇄ `post_mcp_tool_use`, `beforeSubmitPrompt` ⇄ `pre_user_prompt`, `afterAgentResponse` ⇄ `post_cascade_response`, `beforeAgentResponse` ⇄ `post_cascade_response_with_transcript`, and `worktreeCreate` ⇄ `post_setup_worktree`. Canonical events with no Devin equivalent (e.g. `sessionStart`, `stop`) are dropped with a logged warning. The Cascade Hooks file location (`.windsurf/hooks.json` / `~/.codeium/windsurf/hooks.json`) is retained from the Windsurf era and is unaffected by the rebrand.\n\n> **Note:** AugmentCode (Auggie CLI) hooks are merged under the top-level `hooks` key of the shared `.augment/settings.json` (project) / `~/.augment/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). The `hooks` block is merged in place so it coexists with the `toolPermissions` block from the permissions feature. Auggie [documents](https://docs.augmentcode.com/cli/config) that its settings files accept JSON with Comments (comments and trailing commas), so Rulesync reads `settings.json` / `settings.local.json` as JSONC for every feature that shares the file (`mcp`, `hooks`, `permissions`) and edits only the keys it owns, leaving hand-written comments in place; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `notification` ⇄ `Notification`, and `beforeSubmitPrompt` ⇄ `PromptSubmit` (added in Auggie 0.27.0). The `matcher` field (a case-sensitive regex, default `.*`, with `mcp:*` support) applies only to the tool events `PreToolUse`/`PostToolUse`; any matcher on the session events (including `Notification` and `PromptSubmit`) is dropped with a logged warning. Two Auggie-specific fields round-trip as well: a command hook's `args` (extra argv the runner appends, authored as `args` on the canonical hook) and the matcher group's `metadata` (`includeConversationData` / `includeMCPMetadata` / `includeUserContext`, which select what the runner puts in the JSON payload the script receives). `metadata` belongs to the group upstream, so it is authored on any hook of the group and re-applied to every hook of that group on import. Both matter because the `hooks` key is owned in the shared settings file: a value not written here is erased from a hand-written `settings.json` on the next generate. Commands are emitted verbatim — Auggie exposes `AUGMENT_PROJECT_DIR` as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. Only `command`-type hooks are supported. Auggie expresses `timeout` in **milliseconds** (default `60000`), so the canonical seconds value is multiplied by 1000 on generate and divided on import. On **import** (project scope), Rulesync also reads the layered overrides file `<workspace>/.augment/settings.local.json` — a gitignored, machine-specific file that Auggie merges on top of `settings.json` — and combines it over the base settings before importing, following Auggie's documented layering (simple values take the local override, `mcpServers`/`plugins` replace wholesale, and other objects/lists — including the `hooks` events — are combined across tiers), so personal hook overrides are picked up without dropping base events. This overlay is **import-only and project-only**: Rulesync never writes `settings.local.json`, AugmentCode documents no global `~/.augment/settings.local.json`, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes the same hooks format into a plugin bundle's `hooks/hooks.json` (project scope only) as a `{ \"hooks\": { ... } }` document that Rulesync owns as a whole, reading the `augmentcode` override section. Because plugin hook scripts ship inside the plugin, `./`-relative commands are anchored to `$AUGMENT_PLUGIN_ROOT` (the braced `${AUGMENT_PLUGIN_ROOT}` in the exec form) instead of being emitted verbatim. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Note:** IBM Bob hooks are merged under the top-level `hooks` key of the shared `.bob/settings.json` (project) / `~/.bob/settings/settings.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Bob keeps in that file survive a regenerate; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact` (the two compaction events are Bob Shell 2.0.3+; the IDE ignores them), and `stop` ⇄ `Stop`. The `matcher` field applies only to the tool events `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a logged warning. Bob compiles the matcher as a regex over the tool name and treats an omitted matcher as match-all, so the canonical catch-all `\"*\"` (not a valid regex) is emitted as _no_ matcher instead of verbatim. Commands are emitted verbatim (no directory prefix is added). `command` and `http` hooks are supported: Bob Shell spells the webhook handler `https` (`{ \"type\": \"https\", \"url\": \"...\", \"timeout\": <seconds> }`), so a canonical `http` hook is written with `type: \"https\"` and read back as `http` on import; `prompt`, `agent` and `mcp_tool` hooks are skipped. Bob-only entries can be authored under the `bob.hooks` override key. See the [Bob Shell lifecycle hooks docs](https://bob.ibm.com/docs/shell/configuration/lifecycle-hooks) and the [Bob IDE lifecycle hooks docs](https://bob.ibm.com/docs/ide/configuration/lifecycle-hooks).\n\n> **Note:** Tabnine CLI hooks are merged under the top-level `hooks` key of the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global), as per-event arrays of matcher groups (`{ \"EventName\": [ { \"matcher\": \"...\", \"sequential\": true, \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"name\": \"...\", \"description\": \"...\", \"timeout\": 60000, \"env\": { ... } } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Tabnine keeps in that file survive a regenerate — including the sibling `hooksConfig` block (`enabled`, `disabled`, `notifications`), which Rulesync never manages; a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `BeforeAgent`, `stop` ⇄ `AfterAgent`, `preModelInvocation` ⇄ `BeforeModel`, `postModelInvocation` ⇄ `AfterModel`, `beforeToolSelection` ⇄ `BeforeToolSelection`, `preToolUse` ⇄ `BeforeTool`, `postToolUse` ⇄ `AfterTool`, `preCompact` ⇄ `PreCompress`, and `notification` ⇄ `Notification`. Tabnine accepts a `matcher` on every event: a regex over the tool name on `BeforeTool`/`AfterTool`, an exact string on the other events (for example the `Notification` kind), with an omitted matcher or `.*` meaning match-all — so the canonical catch-all `\"*\"` is emitted as _no_ matcher. Tabnine expresses `timeout` in **milliseconds** (default `60000`), so the canonical seconds value is multiplied by 1000 on generate and divided on import; `name`, `description` and `sequential` (which makes the hooks of one matcher group run in order) round-trip through the canonical entry, and `env` is written only when every entry is a safe `KEY=VALUE` pair (an unsafe entry is dropped with a warning). Commands are emitted verbatim — Tabnine expands `$TABNINE_PROJECT_DIR` at runtime, so keep it in the command rather than a resolved path — and only `command`-type hooks are supported; a hook with no `command` is skipped with a warning because Tabnine would discard it. Tabnine-only entries can be authored under the `tabnine.hooks` override key. See the [hooks docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/hooks) and the [hook configuration reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/hooks/configuration).\n\n> **Note:** Snowflake Cortex Code hooks are merged under the top-level `hooks` key of `.cortex/settings.json` (project) / `~/.snowflake/cortex/hooks.json` (global), mirroring Claude Code's per-event matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 60, \"enabled\": true } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Cortex Code keeps in the project file survive a regenerate; a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `subagentStop` ⇄ `SubagentStop`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `permissionRequest` ⇄ `PermissionRequest`, `preCompact` ⇄ `PreCompact`, `notification` ⇄ `Notification`, and `setup` ⇄ `Setup`. Both `command` and `prompt` hook types are written (`http` hooks are skipped with a warning). Cortex Code compiles `matcher` as a case-sensitive regex over the runtime tool name and documents `*` as \"all tools\", so the canonical catch-all is emitted verbatim; as in Claude Code, a matcher on `UserPromptSubmit` or `Stop` is dropped with a logged warning. `timeout` is in seconds (default 60) and the per-hook `enabled` flag round-trips. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CORTEX_PROJECT_DIR\"/…` (Cortex Code exports that variable to hook scripts) and the prefix is stripped again on import; other commands are emitted verbatim. Cortex-Code-only entries can be authored under the `cortexcode.hooks` override key. See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#hooks).\n\n> **Note:** Codewhale hooks are `{ event, command }` entries with snake_case event names: `sessionStart` → `session_start`, `sessionEnd` → `session_end`, `stop` → `turn_end`, `beforeSubmitPrompt` → `message_submit`, `preToolUse` → `tool_call_before`, `postToolUse` → `tool_call_after`, `afterError` → `on_error`, `subagentStart` → `subagent_spawn`, `subagentStop` → `subagent_complete`, and `stopFailure` → `session_error`. Only `command` hooks are emitted; Codewhale runs them through the shell. On the three tool events (`preToolUse`, `postToolUse`, `afterError`) a matcher becomes a `tool_name` condition (a `|`-separated list becomes an `any` condition, and `*` globs are kept); a matcher that is a regular expression cannot be expressed and the hook is skipped with a warning rather than widened to every tool. Matchers are compared against Codewhale's own tool names (for example `exec_shell`, `write_file`, `edit_file`), which Rulesync does not translate. `timeout` becomes `timeout_secs` (a positive whole number of seconds; note that a global `default_timeout_secs` overrides every per-hook value), and `name`, `background`, `continue_on_error` and a raw Codewhale `condition` object pass through. Codewhale-only events (`mode_change`, `shell_env`, `session_idle`, `waiting_for_user`, `session_busy`) can be written by their native name under `codewhale.hooks`, where import also files them, and are emitted verbatim. Codewhale runs a project `.codewhale/hooks.toml` only in a trusted workspace after its exact bytes are approved with `/hooks approve`, so every regeneration that changes the file needs approving again.\n\n> **Note:** Continue hooks are merged under the top-level `hooks` key of `.continue/settings.json` (project) / `~/.continue/settings.json` (global), the settings file of the Continue CLI (`cn`), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 60 } ] } ] }`). Only the `hooks` key is rewritten, so the other settings Continue keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Seventeen events are supported, all under their Claude Code names — `sessionStart`, `sessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop`, `subagentStart`, `subagentStop`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `preCompact`, `notification`, `configChange`, `teammateIdle`, `taskCompleted`, `worktreeCreate` and `worktreeRemove`. Continue accepts `command`, `prompt`, `http` and `agent` hooks (the `mcp_tool` and `function` types are skipped with a warning); a `prompt`/`agent` hook carries its `model`, and the `statusMessage`, `once` and `async` (command hooks only) fields pass through. As in Claude Code, a matcher on an event that takes none (`UserPromptSubmit`, `Stop`, `TeammateIdle`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`) is dropped with a logged warning. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CONTINUE_PROJECT_DIR\"/…` (Continue exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. An `http` hook's `headers` are written as authored into the committable `.continue/settings.json` — Continue expands `$VAR` / `${VAR}` references in hook headers for the variables named in the hook's `allowedEnvVars`, so write `Authorization: Bearer $TOKEN` with `allowedEnvVars: [\"TOKEN\"]` rather than a literal secret. Continue-only entries can be authored under the `continue.hooks` override key. See the [Continue CLI hooks source](https://github.com/continuedev/continue/tree/5522c6f44ca0ac3528b37244818fbfa39b5af470/extensions/cli/src/hooks).\n\n> **Note:** CodeBuddy Code hooks are merged under the top-level `hooks` key of `.codebuddy/settings.json` (project) / `~/.codebuddy/settings.json` (global), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 30 } ] } ] }`). Only the `hooks` key is rewritten, so the `permissions` block and the other settings CodeBuddy keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. The supported events are the lifecycle events CodeBuddy's plugins reference lists for user-defined and plugin hooks alike, under Claude Code's PascalCase names (`beforeSubmitPrompt` ⇄ `UserPromptSubmit`, the others by name: `SessionStart`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `InstructionsLoaded`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, `ElicitationResult`, `SessionEnd`); other canonical events are skipped with a warning. `command`, `http` (`url`, `headers`), `prompt` and `agent` hooks are emitted (`mcp_tool` is skipped with a warning), and a prompt hook's `continueOnBlock` flag is carried both ways. CodeBuddy documents prompt hooks for `Stop`, `UserPromptSubmit` and `PreToolUse` only; rulesync does not filter prompt hooks per event, so keep them on those events. `matcher` is passed through verbatim; `UserPromptSubmit`, `Stop`, `SubagentStop` and `PostCompact` (and the plugin-only events that fire on every occurrence) take none, so a matcher there is dropped with a logged warning. `timeout` is in seconds. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$CODEBUDDY_PROJECT_DIR\"/…` and the prefix is stripped again on import; other commands are emitted verbatim. CodeBuddy-only entries can be authored under the `codebuddy.hooks` override key. See the [hooks docs](https://www.codebuddy.ai/docs/cli/hooks) and the [plugins reference](https://www.codebuddy.ai/docs/cli/plugins-reference).\n\n> **Note:** Command Code hooks are merged under the top-level `hooks` key of `.commandcode/settings.json` (project) / `~/.commandcode/settings.json` (global), in Claude Code's per-event matcher-array shape (`{ \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": 30 } ] } ] }`). Only the `hooks` key is rewritten, so the `permissions` block and the other settings Command Code keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Four events are supported — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `stop` ⇄ `Stop` and `sessionStart` ⇄ `SessionStart`. Only `command` hooks exist (`prompt`, `http` and the other types are skipped with a warning). `matcher` is a regex over the tool name on the two tool events, passed through verbatim; Command Code tests it case-insensitively against its display names (`SHELL`, `READ`, `EDIT`, `WRITE`, `SEARCH` for grep, `GLOB`, `LIST`, `WEB_FETCH`, `WEB_SEARCH`, ...; `*` matches every tool), so a Claude-Code-style `Bash` matcher never fires there — write Command-Code-specific matchers under the `commandcode.hooks` override key. `Stop` and `SessionStart` take none, so a matcher there is dropped with a logged warning. `timeout` is in seconds. A dot-relative command (`./script.sh`, `.rulesync/hooks/x.sh`) is anchored to the project as `\"$COMMANDCODE_PROJECT_DIR\"/…` (Command Code exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. Command-Code-only entries can be authored under the `commandcode.hooks` override key. See the [hooks docs](https://commandcode.ai/docs/hooks).\n\n> **Note:** Letta Code hooks are merged under the top-level `hooks` key of `.letta/settings.json` (project) / `~/.letta/settings.json` (global), in Claude Code's per-event matcher-array shape. The event keys of `hooks` are rewritten as a whole, so existing Letta Code `prompt` hooks there are replaced (with a warning); the `hooks.disabled` switch, the `permissions` block and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Eleven events are supported — `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `notification`, `stop`, `subagentStop`, `preCompact`, and `beforeSubmitPrompt` ⇄ `UserPromptSubmit`. Only `command` hooks are emitted (Letta Code also runs `prompt` hooks, but only on some events, so `prompt` and the other types are skipped with a warning). `matcher` is kept on the four tool events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`) and dropped with a warning elsewhere. `timeout` is written in milliseconds (the canonical value is in seconds) and converted back on import. Letta Code runs hooks with the project directory as their working directory (also exported as `LETTA_WORKING_DIR`), so commands are written verbatim. Letta-Code-only entries can be authored under the `lettacode.hooks` override key. Letta Code's hooks page is not published yet, so this surface follows the [source](https://github.com/letta-ai/letta-code/tree/main/src/hooks).\n\n> **Note:** Qoder hooks are merged under the top-level `hooks` key of `.qoder/settings.json` (project) / `~/.qoder/settings.json` (global), in Claude Code's per-event matcher-array shape; the per-machine `.qoder/settings.local.json` is left to you. The event keys of `hooks` are rewritten as a whole, while the `permissions` block, the `mcpServers` map and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. All 27 documented Qoder events are supported, under Claude Code's PascalCase names (`beforeSubmitPrompt` ⇄ `UserPromptSubmit`). The `command`, `http`, `prompt` and `agent` handler types are emitted (`mcp_tool` is skipped with a warning), with `timeout` in seconds, `model` on `prompt` / `agent` hooks, `url` / `headers` / `allowedEnvVars` on `http` hooks, `if`, `statusMessage` and `once` on any hook, and `shell`, `env`, `args`, `async` and `asyncRewake` on `command` hooks. `matcher` is dropped with a warning on `UserPromptSubmit`, `Stop`, `CwdChanged`, `WorktreeCreate`, `WorktreeRemove`, `TaskCreated`, `TaskCompleted` and `TeammateIdle`, and kept elsewhere. Dot-relative commands (`./x.sh`, `.rulesync/hooks/x.sh`) are anchored with `\"$QODER_PROJECT_DIR\"`, which Qoder exports into every hook process; other commands are written verbatim. Qoder-only entries can be authored under the `qoder.hooks` override key. See the [hooks docs](https://docs.qoder.com/en/cli/hooks).\n> **Note:** Crush hooks are written under the top-level `hooks` key of Crush's shared JSON config — `<project>/crush.json` (project) / `~/.config/crush/crush.json` (global, via `--global`) — as a **flat per-event array** of hook objects with no matcher-group wrapper: `{ \"hooks\": { \"PreToolUse\": [ { \"name\": \"...\", \"matcher\": \"...\", \"command\": \"...\", \"timeout\": 30 } ] } }`. `PreToolUse` is the only event Crush fires (the only one its hooks docs list), so `preToolUse` ⇄ `PreToolUse` is the whole mapping and every other canonical event is skipped with a warning; only `command` hooks are supported. `matcher` is a Go regular expression matched case-sensitively against the lower-case tool name (`bash`, `view`, `edit`, `write`, `mcp_<server>_<tool>`, ...), so it should be spelled in lower case; a canonical `*` or empty matcher is omitted so the hook applies to every tool. `timeout` is an integer number of seconds (Crush defaults to 30), so a fractional canonical value is rounded up. The hook receives a Claude-Code-style JSON payload on stdin (`event`, `tool_name`, `tool_input`, `session_id`, `cwd`) and answers through the JSON output envelope on stdout; exit code 2 blocks the call and exit code 49 halts the turn. At project scope Crush reads both `.crush.json` and `crush.json` and merges them (objects recursively, lists concatenated, `.crush.json` on top), so neither file hides the other. When a `.crush.json` already exists it is the file written (otherwise `crush.json`); if the `crush.json` next to it still carries the keys rulesync owns — for example from a generate run before `.crush.json` was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only `~/.config/crush/crush.json` is read. Only the `hooks` key is rewritten, so the `providers`, `options`, `mcp` and `permissions` keys Crush keeps in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. Crush's newer Bash-based `crushrc` compiles its `hook add` builtin into the same `hooks.PreToolUse` list and overrides the JSON key by key, so a `crushrc` next to the generated file takes precedence. Import reads `hooks.PreToolUse` back as `preToolUse` (Crush accepts the key in any case and with underscores, so `pre_tool_use` folds into the same event); any other event key in the file is filed under the `crush.hooks` override so it round-trips verbatim. See the [hooks docs](https://github.com/charmbracelet/crush/blob/main/docs/hooks/README.md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) hooks are written under the top-level `hooks` key of Pool's shared YAML settings file — `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) / `~/.config/poolside/settings.yaml` (global, via `--global`) — as a **flat per-event list** of hook objects with no matcher-group wrapper: `hooks: { PreToolUse: [ { name: ..., matcher: ..., command: ..., timeout: 60 } ] }`. Six canonical events map onto Pool's: `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preCompact` ⇄ `PreCompact`, `sessionStart` ⇄ `SessionStart` and `stop` ⇄ `Stop`; every other canonical event is skipped with a warning, and only `command` hooks are supported (a `command` hook without a command is skipped with a warning). Pool's schema requires `matcher` on every entry but only tests it on `PreToolUse` and `PostToolUse`, against the tool name (`\"\"` or `*` matches any tool, a bare name is an exact match, `a|b` a list, anything else a regular expression), so the canonical `matcher` is written on those two events (`*` when the hook has none) and a matcher authored on any other event is replaced by the `*` placeholder with a warning, since Pool would ignore it. `timeout` is an integer number of seconds (Pool defaults to 60), so a fractional canonical value is rounded up. Pool runs the hooks of an event sequentially, feeds each a JSON envelope on stdin (`hook_api_version`, `hook_event_name`, `session_id`, `cwd`, plus the event's fields such as `tool_name` and `tool_input`) and reads a decision JSON from stdout; exit code 2 blocks the action with the stderr text as the reason. Pool merges its settings levels user → project → `settings.local.yaml` → `--settings`, where a hook re-declared under the same `name` at a more specific level replaces the earlier one in place and unnamed hooks accumulate, so name a hook when a project should be able to override it. Only the event lists of the `hooks` block are rewritten: `stop_hook_max_continuations` (the cap Pool puts on `Stop` continuations) and the sibling keys Pool keeps in the same file (`pool`, `mcp_servers`, `tools`, `paths`, `sandbox`, ...) survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. Import reads the six events back into their canonical names — a `*` or empty matcher is dropped, as is any matcher on an event Pool never tests it on — and files any other event key under the `pool.hooks` override so it round-trips verbatim. See the [hooks docs](https://docs.poolside.ai/hooks) and the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **Note:** Vibe Code (mistral-vibe) hooks are written to a dedicated `.vibe/hooks.toml` (project) / `~/.vibe/hooks.toml` (global) as a flat `[[hooks]]` TOML array. Each entry carries its own event `type`, a `command`, and optional `name`, `timeout` (seconds, default 60), and `description`. Tool-hook entries (`pre_tool` / `post_tool`) additionally carry a tool-name `match` (an fnmatch glob like `bash`/`mcp_*` or a `re:`-prefixed regex, case-insensitive — the canonical `matcher` field; `*` means \"any tool\") and an optional `strict` flag; `post_agent` carries neither. Three events are supported — `preToolUse` ⇄ `pre_tool`, `postToolUse` ⇄ `post_tool`, and `stop` ⇄ `post_agent` (fires after every assistant turn that ends without pending tool calls). Only `command`-type hooks are emitted. The `command` runs through a shell, so pipes, `&&`, redirects and `$VAR` work. Vibe v2.25.1–v2.25.4 briefly skipped any hook whose `command` contained a backslash; v2.25.5 removed that skip, so Rulesync writes such commands verbatim without a warning. Rulesync cannot detect the installed Vibe version, so if your hook commands contain backslashes and you are on v2.25.1–v2.25.4, upgrade to v2.25.5 or later. Vibe v2.21.0 graduated hooks from experimental: it renamed all three types (`before_tool` → `pre_tool`, `after_tool` → `post_tool`, `post_agent_turn` → `post_agent`) and removed the `enable_experimental_hooks` flag, so declaring a hook is enough and Rulesync no longer writes an auxiliary `.vibe/config.toml`. `HookType` is a strict enum upstream, so an entry using an old name is rejected outright. Import still reads the three old spellings, so a `hooks.toml` left behind from before the rename is repaired into the current names rather than being lost. The `vibe-plugin` target writes the same format into a plugin bundle's `ai.mistral.vibe/hooks.toml` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Cline note (hooks):** Cline's file-based hooks are executables, not a config file: it resolves one script per lifecycle event from `<project>/.clinerules/hooks/` (project) or `~/Documents/Cline/Hooks/` (global, via `--global`), named exactly after the event — the extensionless name on Unix, `<Event>.ps1` on Windows. Rulesync emits a wrapper script per configured event in **both** spellings (the POSIX one with mode `0755`, since Cline spawns the file itself), plus a `rulesync-hooks.json` manifest listing the scripts it owns. The wrapper feeds the event payload it receives on stdin to each configured command in order and answers on stdout with `{\"cancel\": …, \"contextModification\": \"\", \"errorMessage\": …}`: a command exiting `2` cancels the task, any other non-zero exit is surfaced through `errorMessage` without cancelling. Ten canonical events map onto Cline's fixed script names — `sessionStart` → `TaskStart`, `sessionEnd` → `SessionShutdown`, `beforeSubmitPrompt` → `UserPromptSubmit`, `preToolUse` → `PreToolUse`, `postToolUse` → `PostToolUse`, `preCompact` → `PreCompact`, `notification` → `Notification`, `taskCompleted` → `TaskComplete`, `afterError` → `TaskError`, `stopCancelled` → `TaskCancel`. That set is the union of the two runtimes reading the same directory: the VS Code extension's `VALID_HOOK_TYPES` and the SDK/CLI's `HookConfigFileName`, which drops `Notification` but adds `TaskError` and `SessionShutdown`. A script named for an event the running runtime does not know is simply never spawned. That applies to unknown _names_ only: for an event it does know, the SDK/CLI runtime spawns **both** spellings, because it lists hook files per path rather than per event. Each generated script therefore opens with a guard that stands down on the platform the other one owns — the `.ps1` is a no-op off Windows, and the extensionless script is a no-op on Windows. Both are needed: off Windows the runtime runs the `.ps1` through `pwsh`, and on Windows it infers the extensionless file's interpreter from its `#!/bin/bash` shebang and normalizes it to a bare `bash`, so with Git Bash on `PATH` the two spellings **both execute your commands** — a genuine double fire. (The Unix side was noise rather than duplication: the `.ps1` body shells out through `cmd /c`, which Unix does not have, so it failed on every fire instead.) The PowerShell guard tests that `$IsWindows` is both defined and false, since it does not exist at all in the Windows PowerShell 5.1 that `powershell -File` starts; the POSIX guard matches `$OSTYPE`/`uname` against the `msys`/`cygwin`/`mingw` family. `TaskCancel` fires **instead of** `TaskComplete` when a task is aborted (the VS Code extension runs it from `afterRun` when the result status is `aborted`, the SDK/CLI as `agent_abort`), so it is the same shape as canonical `stopCancelled` on Grok CLI and Codex CLI; it is observation-only and cannot cancel anything. Cline's `TaskResume` has no canonical counterpart and is left unmapped; only `command`-type hooks are supported, and `matcher` is ignored because the wrapper is a plain shell script with no payload parser. Each command is passed to `bash -c` as a single quoted argument, so its own quotes and operators cannot break the wrapper; a command that is not valid shell syntax is reported through `errorMessage` instead of cancelling (an unparseable command would otherwise exit `2`). Note that the same command string runs under `bash` on Unix and `cmd /c` on Windows, so shell-specific syntax is not portable across the two generated spellings. Three caveats on ownership: the hooks directory is also where you hand-author your own hooks and the filenames are fixed by Cline, so every generated script carries a `rulesync-owned: cline-hooks` marker line and a script **without** that marker is never overwritten (that event is then not managed by Rulesync, and generate warns about it); a script whose event you remove is rewritten as a no-op rather than deleted, while dropping the `cline` target with `--delete` removes the marked scripts outright; and `rulesync gitignore` lists the generated script names explicitly rather than the whole directory, so a hand-authored hook sharing one of those names needs a negation in your own `.gitignore` if you want to commit it. Generated scripts cannot be imported back into canonical hooks, so this target is generate-only. Cline's in-process hook surface (`AgentHooks` from `@cline/core`) is a separate mechanism that Rulesync does not target. See [`VALID_HOOK_TYPES`](https://github.com/cline/cline/blob/main/apps/vscode/src/core/hooks/utils.ts) and [`HookConfigFileName`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/hooks/hook-file-config.ts) in the Cline source.\n\n> **Note:** Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory `hooks/hooks.json` that Goose auto-discovers at startup. Locations are `<project>/.agents/plugins/rulesync/hooks/hooks.json` (project) and `~/.agents/plugins/rulesync/hooks/hooks.json` (global). The JSON shape matches Claude Code's (`{ \"hooks\": { \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\" } ] } ] } }`). Eleven lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `stop` ⇄ `Stop`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeReadFile` ⇄ `BeforeReadFile`, `afterFileEdit` ⇄ `AfterFileEdit`, `beforeShellExecution` ⇄ `BeforeShellExecution`, and `afterShellExecution` ⇄ `AfterShellExecution` — matching Goose's `HookEvent` enum (it has no `SubagentStart`/`SubagentStop`; its twelfth value, `PreToolUseResult` added in v1.48.0, has no canonical counterpart yet and is not emitted). The `matcher` regex is preserved, commands are emitted verbatim (Goose exposes `PLUGIN_ROOT` as a runtime environment variable), and only `command`-type hooks are supported. A canonical `failClosed: true` on a `preToolUse` command hook is written as Goose's `on_failure: \"block\"` (v1.48.0+), which makes a hook that crashes, times out or returns unparseable output deny the tool call instead of allowing it; `false` is omitted as Goose's default, and on import `on_failure: \"block\"` / `\"allow\"` read back as `failClosed: true` / `false`. Goose only reads `on_failure` on `PreToolUse`, so a `failClosed: true` on any other event is dropped with a warning rather than emitted. One exception applies to the matcher: Goose compiles it with `Regex::new` and **silently drops the whole rule** when compilation fails, and the canonical catch-all `\"*\"` is not a valid regex, so it is emitted as _no_ matcher (which Goose treats as match-all) instead of verbatim.\n\n> **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ \"EventName\": [ { \"matcher\": \"...\", \"sequential\": false, \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Twenty-two lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `postToolBatch` ⇄ `PostToolBatch`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `userPromptExpansion` ⇄ `UserPromptExpansion`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `permissionDenied` ⇄ `PermissionDenied`, `notification` ⇄ `Notification`, `instructionsLoaded` ⇄ `InstructionsLoaded`, `todoCreated` ⇄ `TodoCreated`, `todoCompleted` ⇄ `TodoCompleted`, `messageDisplay` ⇄ `MessageDisplay` (fires repeatedly as the reply streams; added in Qwen Code v0.19.10), and `sessionDelete` ⇄ `SessionDelete` (fires after an explicitly selected session is deleted, via the interactive `/delete` command or the ACP `deleteSession` request; matcher-less, added in Qwen Code v0.21.3). Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt` (which carries the required `prompt` body — with `$ARGUMENTS` interpolation — and an optional `model` override, both round-tripped; a prompt hook without a `prompt` is warned about at generate time since Qwen Code loads it and fails it at runtime), `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The canonical `timeout` (seconds) is converted for command hooks: since [Qwen Code v0.23.4](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.4) ([PR #11615](https://github.com/QwenLM/qwen-code/pull/11615)) a command hook `timeout` is read in seconds, but a value of `1000` or more keeps its legacy millisecond meaning, so a canonical `timeout` below `1000` is written as is and one of `1000` or more is written in milliseconds (`1800` becomes `1800000`). On import, a command hook `timeout` of `1000` or more is read as milliseconds and divided by 1000 (`10000` becomes `10`). http and prompt hook timeouts are always seconds upstream and pass through unchanged, as do `function` hook timeouts (milliseconds upstream, SDK-registered). Qwen Code also honors a numeric-string `timeout` such as `\"60000\"`, so import reads one as the number it spells (before the millisecond rule above); a string that is not a number is dropped, the way Qwen Code falls back to the hook's default for it, and the rest of its matcher group is still imported. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md).\n\n> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ \"EventName\": [ { \"match\": \"...\", \"command\": \"...\", \"description\": \"...\", \"timeout\": ..., \"cwd\": \"...\", \"env\": { ... } } ] }`. All thirteen of Reasonix's events (the `Events` list in `internal/hook/hook.go`) are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `permissionRequest` ⇄ `PermissionRequest`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, `postModelInvocation` ⇄ `PostLLMCall`, `notification` ⇄ `Notification`, and `preCompact` ⇄ `PreCompact`. `match` (Reasonix's matcher field name) is honored only on the tool-scoped events `PreToolUse`/`PostToolUse`/`PostToolUseFailure`/`PermissionRequest`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Each entry also carries the canonical `env` map and a pass-through `cwd` (the hook's working directory; Reasonix defaults to the session's), the same two fields Copilot's hooks take, so both are written on generate and lifted back on import instead of being wiped when rulesync rewrites the entry. Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md).\n\n> **Note:** Grok CLI (xAI Grok Build) hooks are written to a dedicated, standalone `rulesync.json` that Grok auto-discovers from `.grok/hooks/*.json` (project) / `~/.grok/hooks/*.json` (global). The JSON shape is Claude-Code-compatible: each event nests under the top-level `hooks` key as a per-matcher array (`{ \"hooks\": { \"EventName\": [ { \"matcher\": \"...\", \"hooks\": [ { \"type\": \"command\", \"command\": \"...\", \"timeout\": ... } ] } ] } }`). All fifteen documented events map 1:1 onto canonical arms — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `permissionDenied` ⇄ `PermissionDenied`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `stopCancelled` ⇄ `StopCancelled`, `notification` ⇄ `Notification`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, and `postCompact` ⇄ `PostCompact`. `StopCancelled` runs **instead of** `Stop` when a turn ends without completing — a user interrupt, a declined permission prompt, the `--max-turns` limit, or a no-progress bail-out — so a `stop` hook alone does not cover interrupted turns; it is observation-only and cannot block. A `matcher` (a regex) is honored on every event except `Stop` and `UserPromptSubmit`, which always fire; a matcher on either of those two is dropped with a warning. What the regex tests depends on the event: the tool name on `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `PermissionDenied`, the notification type on `Notification` (e.g. `idle_prompt`), the subagent type on `SubagentStart` / `SubagentStop` (e.g. `explore`), the start source on `SessionStart`, the end reason on `SessionEnd`, the compaction trigger (`manual` or `auto`) on `PreCompact` / `PostCompact`, the error type on `StopFailure` (`rate_limit`, `authentication_failed`, …), and the cancellation reason on `StopCancelled` (`user_interrupt`, `permission_rejected`, `permission_cancelled`, `max_turns`, `no_progress`, or `unknown`). Earlier Rulesync versions inferred a much narrower set from Claude Code compatibility and dropped the other matchers, so a hook authored that way fired on everything; regenerate to get them back. Commands are emitted verbatim (Grok documents no project-directory variable). See the [Grok hooks docs](https://docs.x.ai/build/features/hooks). Both handler types Grok defines round-trip: a `command` hook runs a command, and an `http` hook POSTs the payload to its `url`. A command hook's `env` map (upstream `HookConfig.env`, merged into the spawned command's `extra_env`) round-trips as well. Note that a `.rulesync/hooks.*` obtained with `rulesync fetch` can therefore point a Grok hook at any URL — read it before generating.\n\n> **Note:** Kimi Code hooks are global-only and written as flat `[[hooks]]` entries in `~/.kimi-code/config.toml`, with `event`, `command`, and optional `matcher`/`timeout`. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `permissionRequest`, `stop`, `stopFailure`, `notification`, `subagentStart`, `subagentStop`, `preCompact`, and `postCompact`. Kimi's native `PermissionResult`, `Interrupt`, `TurnStarted`, `UserPromptQueued`, `TaskStarted`, and `SessionHeartbeat` events are not mapped from canonical events for Kimi (`Interrupt` is the same shape as canonical `stopCancelled`, which Grok CLI, Codex CLI and Cline map, but it stays native-only on Kimi for now), but they can be written and preserved through the `kimi-code.hooks` override under their native names. (`TaskStarted` is deliberately not folded into the canonical `taskCreated`: it fires when a background task starts and matches on task kind, while `taskCreated` models Claude Code's blocking, matcher-less `TaskCreated` fired during task creation.) Only `command` hooks are emitted. A matcher is dropped with a warning on `Stop`, `SessionHeartbeat`, and `Interrupt`, the three events whose Event Reference row documents the matcher as an empty string. Kimi treats `matcher` as a regular expression tested against the event target, so on these events it is tested against `\"\"` and any non-trivial matcher never matches — such a hook silently never ran. Dropping the matcher is therefore a behavior change for existing configs: the hook now fires, which is what authoring it meant. Every other event matches a real value (`UserPromptSubmit` the submitted prompt text, `PermissionRequest` and `PermissionResult` the tool name, `PreCompact` the trigger, and so on), so matchers there are kept. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as `npm test`. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source `.rulesync/hooks.jsonc`; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. This also means a hook that inspects the session project through `$PWD` or `git rev-parse` sees the source directory instead; such a hook can read the session project from the `cwd` field Kimi passes on stdin, or you can set `\"kimi-code\": { \"runFromSourceDirectory\": false }` in `.rulesync/hooks.jsonc` to emit every command verbatim so it runs in the session project directory, as Claude Code and Codex CLI global hooks do. Opting out restores the exposure described above, so use absolute script paths (e.g. `$HOME/hooks/guard.py`) rather than relative ones. Import does not recover the flag: a re-imported config regenerates with the wrapper unless you set it again. Kimi requires `timeout` to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the [Kimi Code hooks docs](https://moonshotai.github.io/kimi-code/en/customization/hooks.html).\n> **Note:** Codex CLI hooks are written to `.codex/hooks.json` (project) / `~/.codex/hooks.json` (global) with PascalCase event names. The canonical `stopCancelled` event maps to Codex's `Interrupt` (shipped in rust-v0.150.0), which runs when the user interrupts an active turn on the main thread; it never fires for idle threads or subagents, Codex ignores any configured `matcher` on it, its output cannot prevent the interruption (only an optional `systemMessage` JSON is honoured), and command timeouts are capped at 3s (1s by default). Rulesync forwards the hook as authored, so keep `timeout` an integer between 1 and 3 and do not rely on a matcher. See the [Codex hooks docs](https://learn.chatgpt.com/docs/hooks.md).\n\n## `.github/mcp.json` and `.copilot/mcp-config.json`\n\nExample:\n\n```json\n{\n \"mcpServers\": {\n \"serena\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"--from\", \"git+https://github.com/oraios/serena\", \"serena\", \"start-mcp-server\"]\n },\n \"github\": {\n \"type\": \"http\",\n \"url\": \"http://localhost:3000/mcp\"\n },\n \"local-dev\": {\n \"type\": \"local\",\n \"command\": \"node\",\n \"args\": [\"scripts/start-local-mcp.js\"]\n }\n }\n}\n```\n\nThis file is used by the GitHub Copilot CLI for MCP server configuration. Rulesync manages it by converting from the unified `.rulesync/mcp.jsonc` format. Both scopes use the same `{ \"mcpServers\": {...} }` shape but write to different paths:\n\n- **Project mode:** `.github/mcp.json` (relative to project root) — the Copilot CLI auto-loads MCP servers from this workspace config file ([changelog v1.0.61, 2026-06-09](https://github.com/github/copilot-cli)).\n- **Global mode:** `~/.copilot/mcp-config.json` (relative to home directory) — the personal/global MCP configuration.\n\n> **Migration note:** earlier Rulesync versions wrote the **project-mode** Copilot CLI MCP config to `.copilot/mcp-config.json` (the same path used for global mode). Project mode now writes the dedicated workspace file `.github/mcp.json` instead, so a previously generated project-scope `.copilot/mcp-config.json` is no longer managed and can be removed by hand.\n\nRulesync preserves explicit `type` values for `http`, `sse`, and `local` servers. For command-based servers that omit a transport type, Rulesync emits the mandatory `\"type\": \"stdio\"` field required by the Copilot CLI. `streamable-http` is written as `http`, the transport it names, and the canonical `httpUrl` alias is normalized to the `url` Copilot CLI reads. A server the Copilot CLI config cannot express is skipped with a warning rather than failing the run: one that declares no transport at all (the shape a Kilo `{\"enabled\": …}` toggle imports as, which switches off a server some other config layer defines — every entry here defines a server), one that names a remote transport but no `url`/`httpUrl`, one that names a local transport but no `command`, and a `ws` server, since Copilot CLI has no WebSocket transport.\n\nThe canonical per-server `enabledTools` is written as Copilot CLI's own [`tools` allowlist](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers) — `[\"*\"]` (the default) exposes every tool, a list exposes only those names — and imports back as `enabledTools`. A server that already carries a native `tools` value keeps it, and a colliding `enabledTools` is dropped with a warning. `disabledTools` has no counterpart upstream (expressing it would need the server's full tool list), so it is not emitted.\n\n## `rulesync/commands/*.md`\n\nExample:\n\n```md\n---\ndescription: \"Review a pull request\" # command description\ntargets: [\"*\"] # * = all, or specific tools\ncopilot: # copilot specific parameters (optional)\n description: \"Review a pull request\"\n agent: \"agent\" # (optional) VS Code prompt-file agent: \"ask\", \"agent\", \"plan\", or a custom agent name (replaces the deprecated \"mode\")\nantigravity: # antigravity specific parameters\n trigger: \"/review\" # Slash command name; the command is emitted as .agents/skills/review/SKILL.md\ntakt: # takt specific parameters (optional; emitted under .takt/facets/instructions/)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\npi: # pi coding agent specific parameters (optional)\n argument-hint: \"[message]\" # Hint shown in Pi's command palette\ncodexcli: # Codex CLI custom-prompt specific parameters (optional)\n argument-hint: \"[message]\" # Hint shown for the custom prompt's arguments\nroo: # Roo Code specific parameters (optional)\n mode: \"architect\" # (optional) mode slug to switch to before running the command body (e.g. \"code\", \"architect\")\n---\n\ntarget_pr = $ARGUMENTS\n\nIf target_pr is not provided, use the PR of the current branch.\n\nExecute the following in parallel:\n\n...\n```\n\nThe command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping.\n\n> **Antigravity migration note:** Antigravity [retires workflows on October 19, 2026](https://antigravity.google/docs/migration/workflows-to-skills) in favor of skills, so `antigravity-ide` and `antigravity-cli` commands are emitted as skills (`.agents/skills/<name>/SKILL.md`, invoked as `/<name>`) instead of `.agents/workflows/<name>.md`. The workflow-only `turbo` option and the `# Workflow:` header are no longer written. Legacy workflows are still imported by `rulesync import`. Running `rulesync generate --delete` removes every `*.md` file left in the workflow directories, including hand-written workflows, so import those first if you want to keep them. If a rulesync skill with the same name as a command is written to the same skills directory, the skill wins and the command is skipped with a warning, even in a run that does not generate that skill. The skill of a deleted command is removed by `rulesync generate --delete` with the skills feature enabled, because the commands feature does not sweep the shared skills tree.\n\n> **Codex CLI deprecation note:** Codex CLI's own docs now state \"Custom prompts are deprecated. Use skills for reusable instructions\" (see [Custom Prompts](https://developers.openai.com/codex/custom-prompts)). Rulesync's `codexcli` commands still generate the global-only `~/.codex/prompts/*.md` custom-prompt files described above — they remain functional and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `codexcli` skills support (see `.rulesync/skills/*/SKILL.md` below) instead.\n\n> **GitHub Copilot prompt-files note:** the `copilot` target writes commands as VS Code prompt files (`.github/prompts/*.prompt.md`). VS Code's own docs now state that prompt files \"are deprecated for Agent Host sessions and aren't loaded by Agent Host. They continue to work with the Local agent for now, but the Local agent will be removed in a future release\" (see [Prompt files](https://code.visualstudio.com/docs/agent-customization/prompt-files)), and VS Code ships a default-on migration that turns them into agent skills. Rulesync's `copilot` commands still generate the prompt files described above — they remain functional on the Local agent and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `copilot` skills support (see `.rulesync/skills/*/SKILL.md` below), which is not affected by this deprecation.\n\n> **Warp note:** Warp documents skills as its custom slash-command surface — any skill is invocable as `/{skill-name}` with `$ARGUMENTS` / `$ARGUMENTS[N]` / `$N` argument substitution — so rulesync emits each command onto the native skills surface as `.warp/skills/<name>/SKILL.md` (project) / `~/.warp/skills/<name>/SKILL.md` (global, via `--global`), with `name`/`description` frontmatter derived from the command file. Warp's `.warp/workflows/` YAML files are parameterized shell-command templates, not agent prompts, and are deliberately not used. Commands import and `--delete` are no-ops for `warp` because the skills feature owns the `.warp/skills/` tree (importing it as commands would double-import every skill) — mirrors the Devin note below. Keep command and skill names distinct for this target, since a command and a skill sharing a name write the same `SKILL.md` path. See the [Warp skills docs](https://docs.warp.dev/agent-platform/capabilities/skills/).\n\n> **Devin note:** Devin's extensibility docs no longer document a standalone workflows/commands component — reusable prompts invoked as slash commands are [Skills](https://docs.devin.ai/cli/extensibility/skills/overview) (`/name`). Rulesync therefore emits each command onto the native skills surface as `.devin/skills/<name>/SKILL.md` (project) / `~/.config/devin/skills/<name>/SKILL.md` (global, via `--global`), with `name`/`description` frontmatter derived from the command file. The legacy Windsurf/Cascade-era `.devin/workflows/` and `~/.codeium/windsurf/global_workflows/` locations are no longer emitted (stale outputs there stay gitignored but are not cleaned up automatically). Commands import and `--delete` are no-ops for `devin` because the skills feature owns the `.devin/skills/` tree (importing it as commands would double-import every skill). Note that a command and a skill sharing the same name write the same `SKILL.md` path, so keep command and skill names distinct for this target. The `devin-plugin` target writes skills into a plugin bundle's `skills/<name>/SKILL.md` in the same format (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Agent Skills standard note (`agentsskills`):** Rulesync accepts the legacy rulesync spellings on input but always **emits** the shapes the [specification](https://agentskills.io/specification) requires: `allowed-tools` becomes a space-separated scalar (a YAML list is joined), `compatibility` becomes a string (an object is flattened to `key: value` pairs), and `metadata` values are stringified so the block stays a string→string map. Generation also checks the normative constraints and warns — without failing the run — when `name` is empty, longer than 64 characters, contains anything but lowercase letters, digits and single hyphens, or does not match its parent directory name; when `description` is empty or longer than 1024 characters; when `compatibility` exceeds 500 characters; or when an `allowed-tools` list entry contains whitespace, which the space-separated form cannot represent. These are warnings rather than errors because a conformant client only _skips_ such a skill, and because import stays lenient as the [client-implementation guide](https://agentskills.io/client-implementation/adding-skills-support) advises. **Import** reports the same empty-`description` violation, for the one reason it matters on that side too: a conformant client would skip the skill, so a user who is never told has no reason to fix it. Rulesync converts rather than loads, so the skill is imported all the same — dropping the directory would lose content that is still repairable. `hermesagent` reads the same shape and reports it identically. A value that normalizes to the empty string (`compatibility: {}`, `allowed-tools: []`) is dropped rather than written, since the spec requires `compatibility` to be 1–500 characters when present. On **import**, `allowed-tools` is normalized back to the canonical rulesync list, so a generate → import round trip leaves `.rulesync/skills/**` in the shape it started in (the `compatibility` and `metadata` coercions are one-way, because the legacy object/number forms have no conformant equivalent). `hermesagent` reads the same `agentsskills` block and applies the same normalization in both directions, so one rulesync source never produces two different on-disk spellings — except for `metadata`, which stays structured there because Hermes reads `metadata.hermes.*` as YAML. A `hermesagent:` override still wins over the shared block (as for every tool-specific section), so a list or mapping written there is emitted as-is and reported as a spec violation rather than rewritten. Validate the result with the spec's own [`skills-ref validate`](https://github.com/agentskills/agentskills/tree/main/skills-ref). Import leniency is root-based as well as tool-based: any tool scanning an Agent Skills interop root (project `.agents/skills/`, global `~/.agents/skills/`, or Amp's `~/.config/agents/skills/`) skips-and-warns on a skill (directory-form or flat-file) that fails to load there — the cross-vendor directory is where foreign-authored, potentially non-conformant skills live — while each tool's own native root (e.g. Rovo Dev's `.rovodev/skills/`) stays fail-fast.\n\n> **Malformed frontmatter note:** a `SKILL.md` whose YAML fails to parse is retried once with its top-level unquoted values quoted, which recovers the case the Agent Skills [client-implementation guide](https://agentskills.io/client-implementation/adding-skills-support) names — `description: Use this skill when: the user asks about PDFs`, where the second colon ends the scalar. The retry rewrites only top-level `key: value` lines whose value contains a colon followed by whitespace, so a URL (`homepage: https://example.com`) and anything already quoted, a flow collection or a block scalar are left alone. An inline comment is cut off before the value is examined, so `allowed-tools: Read # TODO: add Bash later` is neither rewritten nor turned into a value that grants what the comment had disabled; a file the retry cannot fix still fails with the error it actually has. Recovery is reported with a warning: fix the file itself, since other tools read it with their own parsers.\n\n> **Replit note:** Replit's skills page states conformance to the [Agent Skills specification](https://agentskills.io/specification), so `replit.allowed-tools` accepts either the spec's space-separated string or a canonical rulesync list and is always **emitted** as the string; `replit.compatibility` likewise accepts the spec's string alongside the legacy object form. On import, `allowed-tools` is normalized back to the list, mirroring `deepagents` — so keep list entries free of whitespace, since the space-separated form cannot represent an entry such as `Bash(git commit:*)` and a client would read it back as two. An object `compatibility` is emitted unchanged rather than flattened: unlike the join, that conversion would be one-way, so the legacy form stays as-is and is simply not spec-conformant on disk.\n\n> **Junie skills note:** Junie treats `description` as **optional** in a skill's `SKILL.md` — \"If `description` is not provided in the frontmatter, Junie CLI extracts the first paragraph of the body content as the description.\" Rulesync's canonical frontmatter requires one, so on **import** a missing `description` is filled in the same way, from the first body paragraph (wrapped lines joined into a single line, since it becomes a YAML scalar); a `SKILL.md` Junie loads fine therefore no longer aborts the import. **Markdown headings are skipped**, matching upstream's \"If the body is also empty or contains only headings, the skill will fail to load\" — so a body opening with `# Skill Name` yields the prose beneath it, not the title. That matters beyond the import: an imported description is written back out explicitly on the next generate, so importing the title would replace Junie's own correct fallback with it, for every tool. A fenced code block is **not** special-cased — it is ordinary content, so a body whose first non-heading paragraph is a fence yields the fence text, which is a good reason to author a `description` explicitly. When nothing remains (an empty or headings-only body), Junie could not load the skill either, so Rulesync skips **that one skill** with a warning and keeps importing the rest. Generation always writes an explicit `description`, which Junie's own docs recommend. Junie also loads skills from the shared Agent Skills root — `<projectRoot>/.agents/skills/` and `~/.agents/skills/` — so Rulesync registers it as an import fallback at either scope; it is import-only (the `agentsskills` target owns writing there) and is never removed by Junie-target orphan deletion. When the same skill name exists in both roots, the Junie-specific `.junie/skills/` root takes precedence — Junie's own docs are silent on that ordering, so this matches how Rulesync already resolves the shared root for `kimi-code`. Names are compared case-insensitively there too, so `.agents/skills/My-Skill` does not shadow `.junie/skills/my-skill`; see the cross-root duplicate note under [`.rulesync/skills/*/SKILL.md`](#rulesyncskillsskillmd). Junie's third skill source — the custom folders added with `--skill-location` or the `skill-locations` key of its `config.json` — is out of scope for the `junie` target: Rulesync neither reads nor writes those paths, so a skill kept only there stays invisible to `rulesync import`. See the [agent skills docs](https://junie.jetbrains.com/docs/agent-skills.html).\n\n> **Vibe skills note:** Vibe discovers skills under `.vibe/skills/` (project) and `~/.vibe/skills/` (global), plus the shared `.agents/skills/` root at **both** scopes — Vibe's `user_skills_dirs` returns `~/.vibe/skills` and `~/.agents/skills` alike. Rulesync registers the shared root as an import fallback at either scope; it is import-only and is never removed by Vibe-target orphan deletion. When the same skill name exists in both roots the Vibe-specific root takes precedence, compared case-insensitively; see the cross-root duplicate note under [`.rulesync/skills/*/SKILL.md`](#rulesyncskillsskillmd). The names `vibe` and `skill-creator` are reserved for Vibe's built-in skills: its `SkillManager` skips a project or user skill by either name with only a debug log (legacy backend, still the default; the unified harness loads the built-ins from the shipped `vibe` plugin under namespaced names and does not reserve them), so Rulesync warns at generate time when a Vibe-targeted skill uses one of them (the skill is still written; rename it to make it available in Vibe). **`disable-model-invocation` requires Vibe v2.25.5 or later.** Since v2.25.5 Vibe honors `disable-model-invocation: true` in `SKILL.md`, making the skill explicit-only (the user can still invoke it with a slash command, but the model cannot load it on its own); Rulesync writes the key from the `vibe:` block, falling back to the root-level `disable-model-invocation`, and round-trips it into the `vibe:` block on import. Rulesync emits the key regardless of the installed Vibe version, and older releases ignore it, so on those versions the model can still load the skill on its own — upgrade to v2.25.5 or later to rely on it. The `vibe-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Pi skills note:** Pi implements the [Agent Skills specification](https://agentskills.io/specification), so `pi.allowed-tools` accepts either the spec's space-delimited string or a canonical rulesync list and is always **emitted** as the string; `pi.compatibility` likewise accepts the spec's string alongside the legacy object form. Importing a spec-conformant `SKILL.md` used to fail outright. On import, `allowed-tools` is normalized back to the list, mirroring `deepagents`; keep list entries free of whitespace, since the space-delimited form cannot represent an entry such as `Bash(git commit:*)`. An `allowed-tools` value that normalizes to the empty string (an empty list) is dropped rather than written. An object `compatibility` is emitted unchanged rather than flattened, because that conversion would be one-way.\n\n> **Hermes Agent note:** Commands are global-only and remain distinct from skills. Rulesync writes JSON command specs to `~/.hermes/rulesync/commands/<name>.json`, installs the `rulesync-commands` plugin under `~/.hermes/plugins/`, and enables it in `~/.hermes/config.yaml`. The plugin registers each spec with Hermes's [`ctx.register_command()` plugin API](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins/) and dispatches the prompt through `delegate_task`; invocation arguments are appended to the prompt. `.rulesync/skills/<name>/SKILL.md` still generates a full [Hermes Agent Skill](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills/) under `~/.hermes/skills/<name>/SKILL.md`, which Hermes also exposes as a dynamic slash command. Rulesync rejects command/skill names that would collide in Hermes's slash-command namespace, and rejects nested command paths that flatten to the same name. Generate commands with `rulesync generate --targets hermesagent --features commands --global`.\n>\n> Releases before this native plugin transport emitted Hermes commands as `~/.hermes/skills/<name>/SKILL.md`. Rulesync cannot distinguish those files from real user-authored skills safely, so remove an obsolete legacy file manually after confirming that `.rulesync/skills/<name>/SKILL.md` does not own it.\n\n> **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block followed by the prompt body; besides `description`, Qwen Code's command loader reads `when_to_use` (invocation guidance), `argument-hint` (completion hint), and `disable-model-invocation`, all typed and round-tripped. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block.\n\n> **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`variant`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. Entries under the OpenCode V2 spelling `commands` are read too, since OpenCode V1 also loads them (from v1.18.24): an entry without a `template` is skipped, a `provider/model#variant` model (or a `{ providerID, model, variant }` object) is split into `model` and `variant`, and a `command` entry wins over a `commands` entry of the same name, as in OpenCode. A `subagent` in a `commands` entry is read as `subtask` like everywhere else, although OpenCode V1 itself ignores it there. OpenCode V2 renamed `subtask` to `subagent` (keeping `subtask` as a deprecated alias), while OpenCode V1 accepts only `subtask`, so rulesync reads `subagent` as `subtask` — in Markdown files, inline entries, and the `opencode` (or MiMo Code's `mimocode`) key of `.rulesync/commands/*.md` alike, with `subagent` winning when both are set, as in V2 — and always writes `subtask`, which both versions understand.\n\n> **Kilo Code note:** Custom commands are Markdown files under `.kilo/commands/` (project) / `~/.config/kilo/commands/` (global, via `--global`), invoked as `/<name>` where the name is the file stem. Kilo reserves the `/goal` name for its session-goal command and **throws** when a custom command by that name is found in either directory (`The /goal command is reserved for session goals. Rename the custom command.`), which aborts its whole command scan rather than only the one file. Rulesync therefore skips a rulesync command whose emitted stem is `goal` for the `kilo` target with a warning, at both scopes, and generates the rest; rename the command or exclude it from `kilo` via `targets`. The check runs on the name as written — with the default basename flattening `nested/goal.md` is also skipped, while with the `flattenedCommandNaming: \"path\"` config option it becomes `nested-goal.md` and is written. Import is unaffected: a `goal.md` already in `.kilo/commands/` is read as it is. See [`packages/opencode/src/command/index.ts`](https://github.com/Kilo-Org/kilocode/blob/v7.6.2/packages/opencode/src/command/index.ts).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) reads the OpenCode command format from `.mimocode/commands/*.md` (project) / `~/.config/mimocode/commands/*.md` (global, via `--global`). Its tool-specific frontmatter goes under a `mimocode` key, which takes the same fields as the `opencode` key; on import, inline `command` entries in `.mimocode/mimocode.json(c)` are read as for OpenCode, except the V2 `commands` spelling, which MiMo Code does not load.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads Pi-style Markdown commands from `.omp/commands/*.md` (project) / `~/.omp/agent/commands/*.md` (global, via `--global`), not from Pi's `prompts/` directory. The format is Pi's (`description` and `argument-hint`, with `$1` / `$@` / `$ARGUMENTS` placeholders); tool-specific frontmatter goes under an `omp` key, which takes the same fields as the `pi` key.\n\n> **GitLab Duo CLI note:** [Custom slash commands](https://docs.gitlab.com/user/gitlab_duo_cli/customize/#custom-slash-commands) are Markdown files under the cross-tool `.agents/commands/` (project, the same directory the `agentsmd` target writes) and `~/.gitlab/duo/commands/` (global, via `--global`), invoked as `/<name>` where the name is the file stem. Only the optional `description` frontmatter field is written; GitLab documents no argument placeholders, so the body is passed through as authored. Subdirectories are not supported, and the CLI discovers commands when it starts.\n\n> **AugmentCode note:** Commands are written to `.augment/commands/<name>.md` (project) / `~/.augment/commands/<name>.md` (global, via `--global`). Subdirectories are namespaces — `.augment/commands/git/commit.md` is `/git:commit` — so nested rulesync commands keep their nesting rather than being flattened to a basename. If you generated AugmentCode commands with an earlier Rulesync, the flattened files it wrote are still on disk under their old names; `--delete` removes them. Auggie also discovers commands under the cross-tool `.agents/commands/` root, so **import** reads that root too and treats a command found there as if it lived under `.augment/commands/` — the command's name is its path under whichever root it came from. Generation stays on `.augment/commands/`, and `.agents/commands/` is never written to or swept for orphans, since the files there may belong to another tool — Rulesync itself writes that root for the `agentsmd` target, so a command already imported from `.augment/commands/` is not imported again from there under a flattened name. Auggie's other shared root, `.claude/commands/`, is deliberately not read: it is Claude Code's own output, which Rulesync already imports as that target. Importing from a shared root is announced, because the result is a Rulesync command written for every target on the next generate. See the [custom commands docs](https://docs.augmentcode.com/cli/custom-commands). The `augmentcode-plugin` target writes the same format into a plugin bundle's `commands/` directory (project scope only). Auggie reads only `description` and `model` from a plugin command, so every other field in the `augmentcode` section (such as `argument-hint`) is dropped with a warning; import reads only the bundle's own `commands/`, never `.agents/commands/`. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Reasonix note:** Custom slash commands are Markdown files under `.reasonix/commands/` (project) / `~/.reasonix/commands/` (global, via `--global`) — directly analogous to Claude Code's `.claude/commands/`, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports `description` and `argument-hint`, and the body uses the same `$ARGUMENTS` / `$1`…`$N` placeholder syntax. Subdirectory namespacing is supported (`git/commit.md` → `/git:commit`). Any extra fields are preserved on round-trip under the `reasonix:` block. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md#slash-commands).\n\n> **Grok CLI note:** Custom slash commands are Markdown files under `.grok/commands/` (project) / `~/.grok/commands/` (global, via `--global`), read by the same Claude-Code-compatible frontmatter parser Grok uses for skills. Rulesync emits `description` plus, from the `grokcli:` block, `argument-hint`, `user-invocable` (default true) and `disable-model-invocation` (default false) — the same invocation-control pair Grok skills honor. Two upstream constraints are worth knowing. Grok's command scan is **flat and non-recursive**, so subdirectory namespacing is not supported: a nested `git/commit.md` is flattened onto `commit.md`, and two nested commands with the same basename collide (rulesync warns and the last one wins). And Grok collects skills before commands, letting **skills win name collisions** — a `.grok/skills/<name>/` shadows `.grok/commands/<name>.md`, so avoid giving a rulesync skill and a rulesync command the same name when targeting Grok. Any extra frontmatter keys are preserved on round-trip under the `grokcli:` block. See the [skills, plugins and marketplaces docs](https://docs.x.ai/build/features/skills-plugins-marketplaces).\n\n> **Rovo Dev CLI note:** Rovo Dev's \"saved prompts\" are a file-based custom-command surface made of a `prompts.yml` manifest plus per-prompt Markdown content files, invoked via `/prompts [title] [extra]`. Rulesync writes the content (no frontmatter) to `.rovodev/prompts/<name>.md` (project) / `~/.rovodev/prompts/<name>.md` (global, via `--global`), and rebuilds the sibling `.rovodev/prompts.yml` / `~/.rovodev/prompts.yml` manifest with one `{ name, description, content_file }` entry per prompt, `content_file` pointing at `prompts/<name>.md` (resolved relative to `prompts.yml`, matching Rovo Dev's own resolution order). The `prompts` array is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacing `mcpServers`); any other top-level key in an existing manifest is preserved, and the manifest is never deleted. See the [saved prompts](https://support.atlassian.com/rovo/docs/save-and-reuse-a-prompt-in-rovo-dev-cli/) and [CLI commands](https://support.atlassian.com/rovo/docs/rovo-dev-cli-commands/) docs.\n\n> **ZCode note:** Custom commands are Markdown files under `.zcode/commands/` (project) / `~/.zcode/commands/` (global, via `--global`), invoked from the input box with `/`. Only the global path is spelled out in ZCode's docs, which describe the workspace scope as \"workspace-level commands live in the project directory\"; the `.zcode/commands/` project path is rulesync's inference from that sentence and from the layout ZCode uses for its other workspace assets, not a documented path. The file name is the command's identifier, and the frontmatter carries the `description` shown in the command picker plus an `argument-hint`. ZCode's command scan is flat, so subdirectory namespacing is not modeled. Any extra frontmatter fields are preserved on round-trip under the `zcode:` block. See the [ZCode commands docs](https://zcode.z.ai/en/docs/commands). The `zcode-plugin` target writes the same format into a plugin bundle's `commands/` directory (project scope only), since ZCode parses plugin commands like `.zcode/commands/` ones. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Kimi Code plugin note:** The `kimi-code-plugin` target writes commands into a plugin bundle's `commands/` directory (project scope only), keeping subdirectories. Kimi registers each one as `/<plugin>:<name>`, taking the name from the relative path unless frontmatter `name` overrides it, and substitutes `$ARGUMENTS` in the body. Rulesync writes `description` plus any keys of an optional `kimi-code-plugin` section (such as `name`) as frontmatter, and import moves every key other than `description` back into that section. See [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **CodeBuddy Code note:** Custom slash commands are Markdown files under `.codebuddy/commands/` (project) / `~/.codebuddy/commands/` (global, via `--global`). Subdirectory namespacing is supported: `.codebuddy/commands/frontend/component.md` becomes the `/frontend:component` command. Rulesync emits the canonical `description` (a same-named key in the `codebuddy:` block is ignored); the other documented keys (`argument-hint`, `model`, `allowed-tools`, `disable-model-invocation`) and any extra ones round-trip through the `codebuddy:` block. See the [CodeBuddy Code slash commands docs](https://www.codebuddy.ai/docs/cli/slash-commands).\n\n> **Codewhale note:** User commands are Markdown files under `.codewhale/commands/` (project) / `~/.codewhale/commands/` (global, via `--global`). Codewhale loads the project directory only once the workspace is trusted, scans each directory flat (subdirectories are not supported), and names the command after the lowercased file stem unless frontmatter `name` replaces it. A project command cannot take the name of a protected built-in such as `/trust`, `/undo`, `/config` or `/mcp`; Codewhale skips it (or ignores such an alias) with a load error, and Rulesync warns when a generated project command would hit one, or when a frontmatter `name` is not a single slash-command token (Codewhale then falls back to the file stem). `description` is written from the canonical field, and the other keys of the `codewhale` section (`name`, `usage`, `arguments`, `argument-hint`, `allowed-tools`, `pausable`, `alias` / `aliases`, `hidden`) are written beside it. Codewhale reads the frontmatter line by line as `key: value` pairs rather than as YAML (no YAML quoting or escapes), so Rulesync writes and reads that format directly: every value is written verbatim on one line, a list of strings is joined with `, ` (the separator Codewhale splits `allowed-tools` and `aliases` on), and a nested value or a key other than letters, digits, `-` and `_` is dropped with a warning. On import every value comes back as a string, as Codewhale sees it. `$ARGUMENTS` and `$1`, `$2`, ... are passed through unchanged.\n\n> **IBM Bob note:** Custom slash commands are Markdown files under `.bob/commands/` (project) / `~/.bob/commands/` (global, via `--global`), one `<name>.md` per command, invoked as `/<name>`. The optional YAML frontmatter carries `description` and `argument-hint`; unknown keys are preserved through the `bob` frontmatter block on import. Bob's docs recommend grouping related commands in subdirectories, so nested paths under `.rulesync/commands/` are kept as-is (like Roo). See the [IDE slash commands docs](https://bob.ibm.com/docs/ide/features/slash-commands) and the [Bob Shell slash commands docs](https://bob.ibm.com/docs/shell/features/slash-commands).\n\n> **Tabnine CLI note:** Custom slash commands are **TOML** files under `.tabnine/agent/commands/` (project) / `~/.tabnine/agent/commands/` (global, via `--global`), one `<name>.toml` per command with a `description` string and the `prompt` body, invoked as `/<name>`. Subdirectory namespacing is supported (`.tabnine/agent/commands/git/commit.toml` becomes `/git:commit`), so nested paths under `.rulesync/commands/` are kept as-is. Tabnine CLI documents the Gemini CLI placeholders, so the universal `$ARGUMENTS` is translated to `{{args}}` and `` !`cmd` `` to `!{cmd}` (and back on import — see [Command Syntax](./command-syntax.md)); Tabnine's `@{path}` file-injection form has no universal counterpart and passes through untouched in both directions. Extra TOML keys round-trip through the `tabnine` frontmatter block: an import puts them there and a generate writes them back after `description` and `prompt`. See the [custom commands docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/commands).\n\n> **Continue note:** Custom slash commands are Continue **prompt files** — Markdown under `.continue/prompts/` (project) / `~/.continue/prompts/` (global, via `--global`), one `<name>.md` per command, invoked as `/<name>` once the frontmatter declares `invokable: true`. Rulesync always writes `name` (the file stem), `description` and `invokable: true`, so a file that lacked the flag by hand becomes a slash command after a generate. Placeholders are passed through untouched. Extra frontmatter keys round-trip through the `continue` frontmatter block; an import drops `invokable` (re-emitted on generate) and keeps `name` there only when it differs from the file stem. See the [prompts docs](https://docs.continue.dev/customize/deep-dives/prompts).\n\n> **Command Code note:** Custom slash commands are Markdown files under `.commandcode/commands/` (project) / `~/.commandcode/commands/` (global, via `--global`), one `<name>.md` per command invoked as `/<name>`; subdirectories are allowed and only group the files, the command name stays the file's basename. Command Code runs the file's whole trimmed body as the prompt — YAML frontmatter is not stripped, it is only skipped when the slash menu picks a summary line — so Rulesync writes the bare body without frontmatter; on import a hand-written file's frontmatter block (which Command Code would send as part of the prompt) is dropped and only the body becomes the rulesync command. See the [custom slash commands docs](https://commandcode.ai/docs/custom-slash-commands).\n\n> **Qoder note:** Custom commands are Markdown files with YAML frontmatter under `.qoder/commands/` (project) / `~/.qoder/commands/` (global, via `--global`). The command's invocation name comes from its path, and subdirectories namespace it with `:` (`.rulesync/commands/git/commit.md` becomes `/git:commit`), so nested rulesync commands keep their subdirectories. Rulesync writes the rulesync `description`, which Qoder requires (a command without one is written without it), and the fields of the `qoder` frontmatter block on top (for example `name`, which Qoder only uses as the display label in its command list); on import every key other than `description` goes back into the `qoder` block. See the [commands docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/command).\n\n> **JetBrains AI Assistant note:** the `aiassistant` target does not generate commands. The plugin can read custom chat commands from `.aiassistant/commands/`, but that support is switched off by default behind the registry key `llm.chat.input.language.user.commands.enabled` (default `false` in the plugin's `plugin.xml` as of build 263.5701.47), and the [AI Assistant documentation](https://www.jetbrains.com/help/ai-assistant/) does not describe it, so generated files would be ignored by a default installation. Rulesync will add commands support to the `aiassistant` target once JetBrains enables or documents the directory.\n\n> **Cline workflows note:** Cline reads a workflow's YAML frontmatter: `name` (the slash-command name) and a `disabled` / `enabled` switch ([`user-instruction-config-loader.ts`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/config/user-instruction-config-loader.ts)). The `cline:` section of `.rulesync/commands/*.md` is written as that frontmatter, every frontmatter key of an imported workflow is lifted back into `cline:`, and a command without a `cline:` section is still written as a bare body. `name` must be a string and `disabled` / `enabled` booleans — Cline rejects the whole workflow otherwise — so a value of another type is dropped with a warning. The workflow toggle in Cline's UI is stored in Cline's own state, not in the file, so it is not round-tripped.\n\n## `rulesync/subagents/*.md`\n\nNested sources such as `.rulesync/subagents/review/security.md` retain their\ndirectory structure only for targets that support nested agents (Claude Code and\nKimi Code). Other targets are skipped with a warning; a flat agent with the same\nname can be authored separately for them. Before running `generate --delete`,\nimport any hand-written nested Claude Code agents you want to keep: nested\n`.claude/agents/**` files are now included in the orphan sweep, at both project\nand global scope.\n\n**Upgrade note:** earlier versions only read Markdown files directly under\n`.rulesync/subagents/` and ignored its subdirectories. Since\n[#3187](https://github.com/dyoshikawa/rulesync/pull/3187), every\n`.rulesync/subagents/**/*.md` is loaded, so an existing file such as\n`_drafts/old.md` starts generating for its targets, and Markdown without valid\nsubagent frontmatter (for example `docs/README.md`) is skipped with a warning on\nevery run. Move such files out of `.rulesync/subagents/` after upgrading. For\nClaude Code and Kimi Code, which identify agents by frontmatter `name`, two\nsources that declare the same `name` are both generated, with a warning that the tool may load only one of them.\n\nExample:\n\n```md\n---\nname: planner # subagent name\ntargets: [\"*\"] # * = all, or specific tools\ndescription: >- # subagent description\n This is the general-purpose planner. The user asks the agent to plan to\n suggest a specification, implement a new feature, refactor the codebase, or\n fix a bug. This agent can be called by the user explicitly only.\nclaudecode: # for claudecode-specific parameters\n model: inherit # opus, sonnet, haiku, fable, a full model id, or inherit (default)\n tools: [\"Read\", \"Write\"] # (optional) allowed tools (string or list)\n disallowedTools: [\"Bash\"] # (optional) tools to remove (string or list)\n permissionMode: default # (optional) default | acceptEdits | bypassPermissions | plan\n maxTurns: 20 # (optional) maximum agentic turns\n skills: [\"skill-creator\"] # (optional) Agent Skills to utilize (string or list)\n color: cyan # (optional) UI color (e.g. red, blue, green, cyan, ...)\n memory: project # (optional) user | project | local (`rulesync gitignore` ignores the `local` tree, `.claude/agent-memory-local/`)\n effort: high # (optional) low | medium | high | xhigh | max\n isolation: worktree # (optional) run the subagent in an isolated git worktree\n background: false # (optional) run the subagent in the background\n initialPrompt: \"Start by reading the spec.\" # (optional) seed prompt for the subagent\n mcpServers: {} # (optional) MCP server config (passed through verbatim)\n hooks: {} # (optional) hook config (passed through verbatim)\ncopilot: # for GitHub Copilot specific parameters\n tools:\n # Listed tools are emitted verbatim; omit `tools` entirely to grant the agent\n # all tools. `agent/runSubagent` is opt-in — add it explicitly only when this\n # subagent needs to orchestrate other subagents.\n - web/fetch\n - agent/runSubagent\ncopilotcli: # for GitHub Copilot CLI specific parameters (generated to .github/agents/*.agent.md)\n model: claude-sonnet-4.6 # (optional) model id; inherits the session model when unset\n models: [\"claude-opus-5\", \"claude-sonnet-4.6\"] # (optional, CLI v1.0.81+) models in priority order; overrides `model`\n modelPolicy: preferred # (optional) preferred (default, overridable) | required (locks dispatch to the authored list)\n tools: [\"read\", \"edit\"] # (optional) tools available to the agent (string or list)\nopencode: # for OpenCode-specific parameters\n mode: subagent # (optional, defaults to \"subagent\") OpenCode agent mode\n model: anthropic/claude-sonnet-4-20250514\n variant: high # (optional) default model variant\n temperature: 0.1\n steps: 20 # (optional) max agentic iterations (supersedes the deprecated maxSteps)\n hidden: true # (optional) hide from the @ autocomplete menu (subagents only)\n color: \"#FF5733\" # (optional) hex color or theme color name\n tools:\n write: false\n edit: false\n bash: false\n permission:\n bash:\n \"git diff\": allow\nkilo: # for Kilo-specific parameters\n mode: all # (optional, defaults to \"all\") use \"subagent\" for hidden/subagent-only agents\ncursor: # for Cursor-specific parameters (generated to .cursor/agents/*.md)\n model: inherit # (optional, defaults to \"inherit\") model id, or \"inherit\" to use the parent's model\n readonly: false # (optional, defaults to false) restrict the subagent to read-only tools\n is_background: false # (optional, defaults to false) run the subagent as a background agent\n # On import, a `.cursor/agents/*.md` without `name` (Cursor makes every field optional) takes its name from the file name.\njunie: # for JetBrains Junie CLI specific parameters (generated to .junie/agents/*.md; also imported from .agents/*.md)\n tools: [\"Read\", \"Grep\", \"Edit\"] # allowed tools\n disallowedTools: [\"Bash\", \"WebSearch\"] # disallowed tools\n mcpServers: [\"github\"] # MCP servers the subagent may use\n model: sonnet # model id\n permissionMode: acceptEdits # (optional, defaults to \"default\") default | acceptEdits | dontAsk | bypassPermissions | plan\n reasoningLevel: high # low | medium | high\n maxTurns: 20 # max agentic turns\n skills: [\"kotlin\", \"writerside\"] # Agent Skills to utilize\n allowPromptArgument: true # whether the subagent accepts a prompt argument\ntakt: # takt specific parameters (optional; emitted under .takt/facets/personas/)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\nroo: # for Roo Code specific parameters (optional; aggregated into the root .roomodes file)\n slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name\n whenToUse: \"When planning a task\" # (optional) guidance for automated mode selection\n customInstructions: \"Be concise.\" # (optional) extra behavioral guidelines\n roleDefinition: \"You are the planner.\" # (optional) overrides the body as the mode's roleDefinition\n groups: # (optional, defaults to [\"read\", \"edit\", \"command\", \"mcp\"]) tool access\n - read\n - [\"edit\", { fileRegex: \"\\\\.md$\", description: \"Markdown files\" }]\nbob: # for IBM Bob specific parameters (optional; aggregated into .bob/custom_modes.yaml)\n slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name\n whenToUse: \"When planning a task\" # (optional) guidance for automated mode selection\n customInstructions: \"Be concise.\" # (optional) extra behavioral guidelines\n roleDefinition: \"You are the planner.\" # (optional) overrides the body as the mode's roleDefinition\n groups: # (optional, defaults to [\"read\", \"edit\", \"execute\", \"mcp\"]) Bob tool groups\n - read\n - skill\n allowedSubagents: [\"explore\"] # (optional) subagents the mode may spawn\npool: # for Pool specific parameters (optional; aggregated into the `subagents` block of .poolside/settings.yaml)\n type: in_process # (optional, defaults to \"in_process\") in_process | command | agent_server\n description: \"Plans a task\" # (optional) overrides the top-level description for Pool\n instructions: \"Plan carefully.\" # (optional) overrides the body as the agent's instructions\n command: my-agent # (command type) executable that speaks ACP over stdio\n args: [\"--acp\"] # (command type) arguments passed to the executable\n env: { LOG_LEVEL: debug } # (command type) environment variables for the process\n agent_server: my-agent # (agent_server type) name of a configured ACP agent server\n inherit_agent_config: true # (optional) inherit the parent's model and settings\n disabled: false # (optional) keep the definition but hide the agent from Pool\n---\n\nYou are the planner for any tasks.\n\nBased on the user's instruction, create a plan while analyzing the related files. Then, report the plan in detail. You can output files to @tmp/ if needed.\n\nAttention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code.\n```\n\n> **Claude Code nested subagents:** Rulesync recursively imports Markdown agents from `.claude/agents/` (and the equivalent global directory), preserves their relative paths under `.rulesync/subagents/`, and generates them back to the same nested paths. This makes an `import` followed by `generate --delete` round-trip safe for grouped agents such as `review/security-reviewer.md`. Markdown without valid agent frontmatter (for example, a nested `README.md`) is skipped with a warning during import and is never an orphan-deletion candidate; it stays in place next to valid agents. Imported nested agents are targeted at `claudecode` instead of `*`, because other tools may only support flat agent directories; add other targets explicitly only when they support that layout. When multiple files declare the same frontmatter `name`, the shallower path wins; ties are resolved lexicographically. File paths are also checked separately for case-insensitive collisions, so differently named agents at `Review/Agent.md` and `review/agent.md` cannot overwrite each other on macOS or Windows. Discovery follows symlinks consistently with the other tool import roots described above.\n\n> **Antigravity note:** Antigravity custom agents (CLI v1.1.6+, shared by the IDE and the CLI) are emitted as Markdown + YAML frontmatter to `.agents/agents/<name>.md` (project) and `~/.gemini/config/agents/<name>.md` (global, via `--global`); the body after the frontmatter is the agent's system prompt. Both `antigravity-ide` and `antigravity-cli` read the same two locations, so enabling both writes the same file — the same way they already share `.agents/hooks.json`. Antigravity also accepts a directory form (`<name>/agent.md`); Rulesync emits and imports the flat file form only. `name` and `description` are **required** upstream, so a canonical subagent without a description gets a minimal generated fallback rather than a file Antigravity refuses to load. Because the two share that file, every Antigravity target reads the `antigravity-ide` and `antigravity-cli` blocks merged in a fixed order (the CLI block wins) — the same rule the MCP feature uses for the same shared-output reason — so generation order never changes the file's content; the `antigravity-plugin` block is layered on top for the plugin bundle only. Besides the shared `name`/`description`, those blocks accept these optional fields (all preserved on round-trip): `tools` (string list), `mainAgent` (boolean, default `true`), `subagent` (boolean, default `true`), `model` (`inherit` | `flash` | `pro`), `commandExecutionPolicy` (`off` | `auto` | `eager` | `sandbox`), `mcpServers`, `skills`, and `plugins`. `hidden` and `inheritMcp` appear in the v1.1.6 release notes but not in the documented frontmatter table, so they pass through verbatim with no behavior modeled around them; the schema is loose, so any extra keys survive the round-trip too. The `antigravity-plugin` target writes the same file format into a plugin bundle's `agents/` directory (project scope only). See the [Antigravity subagents docs](https://antigravity.google/docs/subagents) and the [plugin bundle layout](https://antigravity.google/docs/cli/plugins).\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes AugmentCode subagents into a plugin bundle's `agents/<name>.md` (project scope only). Auggie names a plugin agent after its file and reads only `description`, `model` and `hidden` from its frontmatter, so Rulesync keeps those (plus the `name` Rulesync's format requires) and drops every other field in the `augmentcode` section with a warning. That includes `tools` and `disabled_tools`: an agent whose tools were restricted runs with the full tool set when shipped in a plugin, so keep such an agent on the `augmentcode` target if the restriction matters. A subagent whose `name` differs from its file name is warned about, since Auggie shows the file name. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **`.agents/agents/` ownership note (`agentsmd`):** `.agents/agents/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the cross-vendor location AGENTS.md-era clients actually scan for agent definitions: both Antigravity targets generate into it, and Kimi Code reads it alongside its own agents directory. The **simulated** `agentsmd` subagent writer therefore emits there too, and because it has no frontmatter model of its own it emits exactly what the Antigravity targets emit — same merged `antigravity-ide` → `antigravity-cli` blocks, same serialization — so a simulated writer can never degrade the file a native target owns. Earlier Rulesync versions wrote these files to `.agents/subagents/`, a directory no documented client reads; they are no longer generated there (stale outputs stay gitignored but are not cleaned up automatically), so move or delete anything you hand-authored under the old path. Note that, unlike the shared root Kimi Code reads (which is import-only for that target), `.agents/agents/` is a **generated** path for `agentsmd` and the Antigravity targets, so `generate --delete` sweeps orphans there: a hand-authored agent in that directory that no `.rulesync/subagents/*.md` produces is removed. Import it first if you want to keep it. A subagent pinned to one writer of this directory (`targets: [\"antigravity-cli\"]`, say) survives a run that includes the other writers: `generate` claims every path the run writes, across all the targets it runs, and holds the orphan sweep back until the last of them has written, so no writer of a shared directory treats a sibling's output as a leftover. Narrowing `--targets` narrows that claim set, though — a later `generate --targets antigravity-ide --delete` knows nothing about the `antigravity-cli`-pinned file and sweeps it — so generate the writers of a shared directory together. Because the file is shared, an `agentsmd`-only generate still emits the Antigravity blocks (`tools`, `model`, `commandExecutionPolicy`, `mcpServers`, …) verbatim: writing a reduced file would be exactly the silent degradation this shared-path arrangement exists to prevent. That is a deliberate trade-off — a subagent pulled in from someone else's repository with `rulesync fetch` becomes a live, executable agent definition as soon as any target that writes this path runs, so review `.rulesync/subagents/*.md` after fetching, exactly as you would review any other fetched configuration before generating.\n\n> **Qwen Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.qwen/agents/` (project) and `~/.qwen/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Besides the shared `name`/`description`, the `qwencode:` block accepts these optional fields (all preserved on round-trip): `model`, `approvalMode` (`default` | `plan` | `auto-edit` | `yolo` | `bubble`), `tools` (allowlist), `disallowedTools` (denylist), `maxTurns`, `color`, `mcpServers` (per-agent MCP overrides — accepts both a record of server specs, matching Qwen's documented shape, and a plain array of server names), and `hooks` (per-agent hook registrations). See the [Qwen Code sub-agents docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/sub-agents.md).\n\n> **Tabnine CLI note:** Subagents are emitted as Markdown + YAML frontmatter under `.tabnine/agent/agents/` (project) and `~/.tabnine/agent/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Tabnine requires `name` and `description`, so a rulesync subagent without a description is written with the placeholder `<name> subagent` and a warning; the `tabnine:` block accepts the optional `kind` (`local` | `remote`), `tools` (allowlist of tool names), `model`, `temperature`, `max_turns` and `timeout_mins` fields, all preserved on round-trip together with any key Tabnine adds later. Subagents are an experimental Tabnine CLI feature that must be switched on with `experimental.enableAgents: true` in `settings.json` — Rulesync writes the agent files only and leaves that flag to you. See the [subagents docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/subagents).\n\n> **Snowflake Cortex Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.cortex/agents/` (project) and `~/.snowflake/cortex/agents/` (user/global, via `--global`); the body is the subagent's system prompt. Cortex Code requires `name`; the `cortexcode:` block accepts the optional `description`, `tools` (allowlist of tool names) and `model` fields, all preserved on round-trip together with any key Cortex Code adds later. (Cortex Code also falls back to `.claude/agents/`, which the `claudecode` target already covers.) See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#subagents).\n\n> **Codewhale note:** Subagents are emitted as TOML agent profiles under `.codewhale/agents/<id>.toml` (project) and `~/.codewhale/agents/` (global, via `--global`). Rulesync writes `id` (the file name, with characters other than letters, digits, `.`, `_` and `-` replaced by `_`), `display_name` (the subagent `name`), `description`, and the body as `[instructions] text`. Codewhale rejects profiles with unknown fields, so only the `role_hint`, `base_role`, `loadout`, `model`, `provider` and `reasoning_effort` keys of the `codewhale` section are copied, and only when their value is a string; any other key or value is dropped with a warning. The `codewhale.tools` and `codewhale.permissions` tables become the profile's `[tools]` / `[permissions]` tables, but Codewhale rejects a profile that would widen access there, so only the narrowing values are written: `tools.posture` set to `read-only` (or `readonly` / `read_only`), and `permissions.allow_shell = false`, `permissions.trust = false` and `permissions.approval_required = true`; any other entry is dropped with a warning. As of Codewhale v0.10.1 these values match the floor every profile already gets, so they keep a profile loadable and round-trip through import rather than narrowing access further; effective access comes from `base_role` / `role_hint` and the parent session's posture. Codewhale also accepts `model_hint` / `model_id` for `model` and `thinking` / `reasoning` for `reasoning_effort`; those spellings are written under the canonical key (the canonical spelling wins when both are set). Import reads `display_name` (falling back to `name`, then `id`), `[instructions] text` (falling back to the legacy `persona`), the alias spellings into `model` / `reasoning_effort`, and the narrowing `[tools]` / `[permissions]` entries into the matching `codewhale` tables.\n\n> **Command Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.commandcode/agents/` (project) and `~/.commandcode/agents/` (user/global, via `--global`); the body is the agent's system prompt. Command Code requires `name`; `name` and `description` come from the rulesync subagent's top-level fields, and the `commandcode:` block accepts the documented optional fields — `tools` and `disallowedTools` (a comma/space-separated string or a YAML array, `\"*\"` grants every tool), `model`, `reasoningEffort`, `maxTurns`, `permissionMode`, `background` and `showOutput` — all preserved on round-trip together with any key Command Code adds later. Command Code reserves the names `explore`, `plan`, `review` and `general` for its built-in agents and silently ignores a custom agent whose `name` — sanitized to `[a-zA-Z0-9_-]` and lowercased, so `Review` counts — is one of them; rulesync still writes such a subagent but warns, since the name is yours to change. See the [custom agents docs](https://commandcode.ai/docs/custom-agents).\n\n> **Qoder note:** Subagents are Markdown + YAML frontmatter under `.qoder/agents/` (project) and `~/.qoder/agents/` (global, via `--global`); the body is the subagent's system prompt, and its identity is the frontmatter `name`, not the file name. `name` and `description` come from the rulesync subagent's top-level fields, and the `qoder:` block accepts Qoder's optional fields — `tools` and `disallowedTools` (a comma-separated string or a YAML list), `model`, `permissionMode`, `maxTurns`, `timeoutMins`, `color`, `skills`, `isolation`, `mcpServers`, `hooks` and any key Qoder adds later — all preserved on round-trip. See the [subagent docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/subagent).\n\n> **Letta Code note:** Subagents are emitted as Markdown + YAML frontmatter under `.letta/agents/` (project) and `~/.letta/agents/` (user/global, via `--global`); the body is the subagent's system prompt, and a project subagent overrides a user one of the same name. Letta Code requires `name` and `description`, both taken from the rulesync subagent's top-level fields; the `lettacode:` block carries the optional fields Letta Code reads — `tools` (a comma-separated string such as `Glob, Grep, Read`, or `all`), `model`, `skills`, `fork` and `launchProfile` — and any other key, all preserved on round-trip. Letta Code reads `tools` and `skills` only as comma-separated strings (a YAML list would be ignored, which for `tools` means every tool), so a list is written joined with `, `. Letta Code reads frontmatter with its own line-based parser, which does not unquote values, so a string that YAML would have to quote (a description containing `: `, say) is written as a `|-` block scalar instead. Letta Code skips a subagent whose `name` is not lowercase letters, digits and hyphens starting with a letter, one named `claude-code` or `codex` (reserved), and one without a `description` (unless its body is empty, which overlays the built-in subagent of that name); Rulesync still writes such a subagent but warns. See the [subagents docs](https://docs.letta.com/configuration/subagents/index.md).\n\n> **Kimi Code note:** Subagents are emitted as Markdown files under `.kimi-code/agents/` (project) and `~/.kimi-code/agents/` (global). The shared `name` and required `description` fields are written to YAML frontmatter; Kimi-specific `whenToUse`, `override`, `tools`, `disallowedTools`, and `subagents` fields can be authored under the `kimi-code:` block and round-trip unchanged. Kimi recursively scans both its Kimi-specific agents directory and the shared `.agents/agents/` directory, so Rulesync imports nested Markdown files from both locations and flattens them into `.rulesync/subagents/<name>.md` using the validated kebab-case agent name. The Kimi-specific root has precedence over `.agents/agents/`; if multiple source files resolve to the same logical agent name, the first one wins and Rulesync warns about the duplicate. The shared root is import-only and is never removed by Kimi-target orphan deletion. See the [Kimi Code custom-agents docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html). The `kimi-code-plugin` target writes the same format into a plugin bundle's `agents/<name>.md` (project scope only); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **Kiro CLI note:** Subagents are emitted as JSON agent configurations under `.kiro/agents/` (project) and `~/.kiro/agents/` (global). Kiro allows the JSON `name` field to be omitted, in which case the filename stem is the agent name; Rulesync accepts that form on import and writes the derived name into the Rulesync frontmatter. Imports through the `kiro-cli` target retain `targets: [\"kiro-cli\"]`, so they can be generated back to the same target without changing the target metadata.\n\n> **Cline note:** Cline file-based agents are emitted as YAML files (`<name>.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block followed by the system prompt body, matching Cline's agent config loader: `name` and `description` are **required** (Cline cli-v3.0.23+ refuses to load an agent whose `description` is missing or empty — a canonical subagent without one gets a minimal generated fallback rather than a file Cline cannot load), and the typed optional fields `tools`, `skills`, `providerId`, `modelId`, and `maxIterations` round-trip through the `cline:` section. Import reads `.yml` alongside `.yaml`, matching Cline's `isYamlFile()`.\n\n> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents/<name>/AGENT.md` (project) and `~/.config/devin/agents/<name>/AGENT.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents). The `devin-plugin` target writes the same format into a plugin bundle's `agents/<name>/AGENT.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Reasonix note:** Reasonix native subagents are Skill profiles emitted as `SKILL.md` files in a **directory-per-agent** layout: `.reasonix/skills/<name>/SKILL.md` (project) and `~/.reasonix/skills/<name>/SKILL.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). A subagent is a Skill whose YAML frontmatter declares `invocation: manual` and `runAs: subagent` — Rulesync always injects both markers so the SKILL.md is recognized as a manually invoked subagent rather than an auto-discovered skill. Besides the shared `name`/`description`, the `reasonix` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, subagent LLM), `effort` (string, reasoning effort), `allowed-tools` (list of strings, restrict available tools), and `color` (string, display color). The schema is loose, so any extra keys survive the round-trip. See the [Reasonix subagent profiles docs](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SUBAGENT_PROFILES.md).\n\n> **Roo / Zoo Code mode-specific rules note:** Both tools load `.roo/rules-{mode}/` (global `~/.roo/rules-{mode}/`) **instead of** the mode-agnostic `.roo/rules/` while that custom mode is active. Set a `roo:` frontmatter section with `mode: architect` on a rule to route it there; the section is shared by the `roo` and `zoocode` targets, which write the same `.roo/` tree. The slug must match `^[a-zA-Z0-9-]+$` (the alphabet the tools themselves accept) — anything else is ignored with a warning and the rule lands in `.roo/rules/`, rather than interpolating an arbitrary string into a directory name. The key is ignored on the `root: true` rule, which has no mode-specific counterpart. On import, a file under a mode directory comes back as `.rulesync/rules/{name}-{mode}.md` carrying `roo.mode`, suffixed so it cannot collide with a same-named generic rule (an already-suffixed name is left alone, so repeated generate/import cycles converge) and targeted at the importing tool alone, since a wildcard would make every other target emit a mode-scoped rule as an always-on one; mode-directory import is project scope only. Note that mode directories are **not swept by orphan deletion** — `generate --delete` only clears `.roo/rules/` — because a `rules-*` glob would also match mode rules you wrote by hand, so a file left behind by a removed rule has to be deleted yourself. Mode-specific _skills_ need no directory support: Zoo Code reads `modeSlugs` from a skill's own frontmatter at higher priority than the directory it sits in, so the existing `roo: modeSlugs` frontmatter already scopes a skill in `.roo/skills/` to a mode.\n\n> **Roo skills/commands note (final v3.54.0 state — Roo Code is EOL and its repository archived):** Commands are generated to `.roo/commands/` (project) and `~/.roo/commands/` (global, via `--global`; project wins on a name collision). Skill frontmatter beyond `name`/`description` — most usefully `modeSlugs: string[]` for mode targeting — is authored via the `roo:` section of `.rulesync/skills/*/SKILL.md` and lifted back into it on import, so it survives the round-trip. A localRoot rule is emitted as `AGENTS.local.md`, the personal, gitignored override file Roo loads alongside `AGENTS.md`.\n\n> **Zoo Code note:** Zoo Code ([Zoo-Code-Org/Zoo-Code](https://github.com/Zoo-Code-Org/Zoo-Code)) is the community continuation of the archived Roo Code, named by the Roo shutdown notice and continuing Roo's release numbering (v3.54.0 → v3.72.0 as of 2026-07-25). It still resolves `~/.roo` and the project `.roo/` layout — the `.zoo` renaming is confined to provider/auth code — so the `zoocode` target reuses the `roo` adapters' path model verbatim across rules (including `AGENTS.local.md` local-root handling), ignore (`.rooignore`), MCP (`.roo/mcp.json`), commands (`.roo/commands/`), skills (`.roo/skills/`, `roo:` frontmatter section), and subagents (the aggregated `.roomodes` file). Shared mode/skill fields keep riding the `roo:` frontmatter sections, so one rulesync source never produces two spellings; targeting both `roo` and `zoocode` writes the same files, so pick one target per project — and note the fail-open hazard the shared `.roomodes` creates: a `--targets roo` generate rewrites it **without** `allowedMcpServers`, so opening that workspace in Zoo Code makes every MCP server available to the mode. The post-fork divergence is carried by the `zoocode:` subagent section: `allowedMcpServers` (Zoo Code v3.60.0+), a per-mode MCP server allowlist (\"when omitted, all servers are available; when set, only the listed servers are injected\"), emitted into the mode and lifted back into `zoocode:` on import. See the [Zoo Code docs](https://docs.zoocode.dev/features/custom-modes).\n\n> **Vibe note:** Vibe agent profiles are emitted as TOML to `.vibe/agents/<name>.toml` (project) and `~/.vibe/agents/<name>.toml` (global, via `--global`). The subagent body is **not** written into the profile: Vibe's settable field is `system_prompt_id`, while `system_prompt` is a read-only property on its config schema and unknown TOML keys are ignored rather than rejected — so a profile carrying `system_prompt` loads fine and silently runs with the **default** system prompt. Rulesync therefore writes the body to `.vibe/prompts/<name>.md` and sets `system_prompt_id = \"<name>\"`, the same mechanism Vibe's own builtin profiles use (`EXPLORE` sets `\"system_prompt_id\": \"explore\"`). The two files are always written together, because `VibeConfigSchema._check_system_prompt` evaluates the id during validation and an unresolvable one makes `AgentRegistry._try_load` drop the agent with a warning. On import, `system_prompt_id` is resolved against `.vibe/prompts/` and becomes the canonical body; a legacy `system_prompt` is still read, and an id that resolves to nothing is preserved in the `vibe:` block so a hand-maintained prompt file keeps working. A subagent with an empty body writes no prompt file and leaves any `system_prompt_id` you authored alone. Note that `.vibe/prompts/` is not swept by orphan deletion — `generate --delete` only clears `.vibe/agents/` — so a prompt file left behind by a removed subagent has to be deleted by hand. The `vibe-plugin` target writes subagents into a plugin bundle's `ai.mistral.vibe/agents/<name>.toml` (project scope only), in Vibe's plugin agent document with the prompt inline as `instructions`. See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **Roo note (as of 2026-06-16):** Roo Code reads project custom modes from a single aggregated `.roomodes` file at the workspace root (YAML; JSON also accepted). Rulesync therefore collapses every Roo-targeted subagent into that file's `customModes` array — each subagent becomes one mode whose `slug` is derived from the file name (sanitized to `^[a-zA-Z0-9-]+$`), `name`/`description` come from the shared frontmatter, and `roleDefinition` is the subagent body. The optional `roo:` block supplies `groups` (defaults to `[\"read\", \"edit\", \"command\", \"mcp\"]`), `whenToUse`, `customInstructions`, an explicit `slug`, and a `roleDefinition` override. (Roo's previous `.roo/subagents/` output was inert — Roo Code never read it.) See the [Roo custom-modes docs](https://roocodeinc.github.io/Roo-Code/features/custom-modes).\n\n> **IBM Bob note:** IBM Bob's native subagents are two fixed presets (`explore` and `general`), so Rulesync maps subagents onto Bob's custom modes instead, the same way the `roo` target fills `.roomodes`. Every Bob-targeted subagent becomes one entry in the `customModes` array of the project file `.bob/custom_modes.yaml` (project scope only: Bob IDE documents `~/.bob/settings/custom_modes.yaml` as the global file while Bob Shell documents `~/.bob/custom_modes.yaml`, so no global file is written). The `slug` is derived from the file name (sanitized to `^[a-zA-Z0-9-]+$`), `name`/`description` come from the shared frontmatter, and `roleDefinition` is the subagent body. The optional `bob:` block supplies `groups`, `whenToUse`, `customInstructions`, `allowedSubagents`, an explicit `slug`, and a `roleDefinition` override. It is separate from the `roo:` block because Bob spells its tool groups differently (`read`, `edit`, `execute`, `mcp`, `skill`, `workflow`, `todo`, `subtask`, `subagent`, `mode`, as Bob IDE documents them, where Roo has `command` instead of `execute`); without `bob.groups` a mode gets `[\"read\", \"edit\", \"execute\", \"mcp\"]`. The Bob Shell custom-modes page still lists Roo's `read`, `edit`, `browser`, `command`, `mcp` instead, so set `bob.groups` explicitly for a mode you use in Bob Shell. On import every mode comes back as its own `.rulesync/subagents/<slug>.md` targeted at `bob`, with the other mode fields in the `bob:` block. Rulesync owns the whole file: generating rewrites it from `.rulesync/subagents/`, and `generate --delete` removes it when no subagent targets `bob`, so import any hand-written modes first. See the [Bob custom modes docs](https://bob.ibm.com/docs/ide/configuration/custom-modes).\n\n> **OpenCode import note:** OpenCode lets agents live both as Markdown files under `.opencode/agents/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `agent` key. On import, rulesync reads both: each inline entry's `prompt` becomes the subagent body (a `\"{file:./path}\"` reference is resolved relative to the config file's location, as OpenCode does), and the remaining fields (`description`/`mode`/`model`/`tools`/`permission`/...) become frontmatter under the `opencode:` block. Entries under the OpenCode V2 spelling `agents` are read too, lowered the way OpenCode V1 (from v1.18.24) lowers them: `system` becomes the body, `disabled` becomes `disable`, `request.body` becomes `options`, a `provider/model#variant` model (or a `{ providerID, model, variant }` object) is split into `model` and `variant`, other V2-only fields (such as `permissions`) are not read, and an `agent` entry wins over an `agents` entry of the same name. A Markdown file takes precedence over an inline entry with the same name, compared case-insensitively — `.opencode/agents/planner.md` and an inline `Planner` are one `.rulesync/subagents/` file on macOS and Windows, so the inline copy is dropped with a warning rather than silently overwriting the file (two inline entries differing only in case resolve the same way, keeping the earlier one).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) reads the OpenCode agent format from `.mimocode/agents/*.md` (project) / `~/.config/mimocode/agents/*.md` (global, via `--global`). Put its tool-specific frontmatter under a `mimocode` key, which takes the same fields as the `opencode` key; on import, inline `agent` entries in `.mimocode/mimocode.json(c)` are read as for OpenCode, except the V2 `agents` spelling, which MiMo Code does not load.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads Markdown agent definitions from `.omp/agents/*.md` (project) / `~/.omp/agent/agents/*.md` (global, via `--global`). It ignores an agent without both `name` and `description`, so Rulesync falls back to the `name` when a subagent has no `description`. Put oh-my-pi's own frontmatter (`tools`, `model`, `spawns`, `thinkingLevel`, `blocking`, …) under an `omp` key; it is copied verbatim. The names `main` and `sub` are reserved by oh-my-pi.\n\n> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior.\n\nBesides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip):\n\n| Field | Type | Notes |\n| ------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `displayName` | string | Human-friendly name shown in pickers |\n| `model` | string | Model id |\n| `variant` | string | Model variant |\n| `temperature` | number | Sampling temperature |\n| `top_p` | number | Nucleus-sampling parameter |\n| `permission` | string \\| object | Permission profile name, or a per-tool `{ <tool>: { allow, deny, ask } }` object |\n| `prompt` | string | Inline system prompt |\n| `color` | string | UI color |\n| `native` | boolean | Native (built-in) agent flag |\n| `hidden` | boolean | Hide from top-level picker |\n| `disable` | boolean | Disable the agent |\n| `deprecated` | boolean | Mark as deprecated |\n| `steps` | positive integer | Maximum agentic iterations before a text-only response is forced (an explicit `null` is accepted and round-trips as-is, so a file that already carries one still imports; earlier Rulesync versions took a list of step objects here, which Kilo never accepted) |\n| `options` | object | Free-form key/value options |\n\n> **Migration note (`steps`):** earlier Rulesync versions typed `steps` as a list of step objects, which Kilo never accepted — a subagent authored that way produced a file Kilo ignored. It is now the iteration count Kilo documents, so a `kilo` block (or a `.kilo/agents/*.md` file) still carrying the list form fails validation with the offending file named, and the run stops rather than writing a file that would not work. Replace the list with the number of iterations you want, or drop the field.\n\n> **Hermes Agent note:** Project generation writes subagent JSON specs under `.hermes/rulesync/subagents/` and installs `.hermes/plugins/rulesync-subagents/`. The plugin resolves specs relative to its own installation, so the same code works in project and global scope. For project scope, Rulesync also enables `rulesync-subagents` in `$HERMES_HOME/config.yaml`. Run Hermes from the trusted project root with `HERMES_ENABLE_PROJECT_PLUGINS=true`; Rulesync deliberately does not persist that global trust gate.\n\n> **Pool note:** Pool has no per-agent files: every subagent is an entry of the `subagents.agents` map in its settings file, `.poolside/settings.yaml` for the project and `~/.config/poolside/settings.yaml` for `--global`. Rulesync writes one entry per subagent, keyed by the frontmatter `name`, with `type` (defaulting to `in_process`), `description` and `instructions` (the Markdown body, unless `pool.instructions` overrides it) plus whatever else the `pool:` block carries, such as `command`/`args`/`env` for a `command` agent or `agent_server` for an `agent_server` one. Rulesync owns the whole `subagents` block: on every run it recomputes `subagents.agents` from the Rulesync subagents targeting Pool, dropping agents a previous run generated but keeps `subagents.default` and Pool's built-in `general` entry (a Rulesync subagent named `general` is skipped with a warning) and leaves the file's other keys untouched; a run with no Pool-targeted subagent therefore removes the generated agents instead of leaving stale ones behind, as long as a `.rulesync/subagents/` directory exists (a project without one has not adopted the feature and its agents are left alone, and `rulesync convert --to pool` likewise never retracts, since its sources come from another tool). Every agent Rulesync removes is named in a warning. Hand-written agents that should survive generation belong in `.poolside/settings.local.yaml`, which Rulesync never touches; at global scope, where Pool has no such overlay, keep them in `.rulesync/subagents/` instead. `rulesync import --targets pool` fans `subagents.agents` (except `general`) back out to one `.rulesync/subagents/<name>.md` per agent, moving `instructions` into the body and the remaining keys into the `pool:` block. See the [Pool subagents docs](https://docs.poolside.ai/subagents) and [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **ZCode note:** ZCode subagents are written to `.zcode/agents/<name>.md` (project) and `~/.zcode/agents/<name>.md` (global, via `--global`). ZCode's docs describe only the user directory — the Settings UI manages user-level subagents alone — but its agent runtime also loads `<project>/.zcode/agents/` (observed in the v3.14.3 runtime; not yet documented). Project-scope subagents are parsed like user ones except that `permissionMode` is discarded, so a `permissionMode` in the `zcode:` block is written for `--global` only and dropped with a warning in project mode. Each subagent is one Markdown file named after the agent, with YAML frontmatter whose keys are camelCase and case-sensitive: `name` and `description` are required, and `model`, `thoughtLevel`, `color`, `tools`, `disallowedTools`, `maxTurns` (a positive integer), `injectAgentsMd` and `mcpServers` are optional. Put those optional fields in the `zcode:` block of the canonical subagent; any extra keys are preserved on round-trip through the same block. See the [ZCode subagents docs](https://zcode.z.ai/en/docs/subagents). The `zcode-plugin` target writes the same format into a plugin bundle's `agents/<name>.md` (project scope only); ZCode reads plugin agents like `~/.zcode/agents/` ones, so `permissionMode` is kept there. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **CodeBuddy Code note:** Subagents are written to `.codebuddy/agents/<name>.md` (project) and `~/.codebuddy/agents/<name>.md` (global, via `--global`) with the required `name` and `description`, both taken from the canonical subagent (same-named keys in the `codebuddy:` block are ignored, so `name` always matches the file name). The optional keys CodeBuddy documents (`tools`, `model`, `permissionMode`, `skills`, `mcpServers`, `disallowedTools`, `effort`, `maxTurns`, `background`, `initialPrompt`, `memory`, `hooks`) go in the `codebuddy:` block and are written verbatim, so `tools` can stay the comma-separated string CodeBuddy's docs use. See the [CodeBuddy Code sub-agents docs](https://www.codebuddy.ai/docs/cli/sub-agents).\n\n## `.rulesync/checks/*.md`\n\nCode review checks are per-check instructions an agent runs during code review. Each check is a single Markdown file with YAML frontmatter (the source of the check identity is the file name — e.g. `.rulesync/checks/security.md` defines the `security` check).\n\nExample:\n\n```md\n---\ntargets: [\"*\"] # * = all, or specific tools\ndescription: Flags common security issues # (optional) short summary of the check\nseverity: high # (optional) low | medium | high | critical\ntools: [\"Read\", \"Grep\"] # (optional) tool names the check may use\n---\n\nReview the diff for injection vulnerabilities, hardcoded secrets, and unsafe\ndeserialization. Report each finding with a file and line reference.\n```\n\nAmp, AugmentCode, Cursor, Factory Droid, GitLab Duo CLI, Goose (global only), Hermes Agent, Kilo Code, Qwen Code, Rovo Dev CLI and Takt consume checks. Amp receives one Markdown file per check:\n\n- **Project scope:** `.agents/checks/<name>.md`\n- **Global scope** (`--global`): `~/.config/amp/checks/<name>.md`\n\nFor Cursor, checks are [Bugbot](https://cursor.com/docs/bugbot) code review instructions, and Bugbot reads one aggregated instruction file per directory rather than a file per check — so every check targeting Cursor collapses into the repository-root `.cursor/BUGBOT.md`. Each check becomes one section: an HTML-comment marker carrying the check name, an `## <name>` heading, and the check body as the instruction text (the `description` is used when the body is empty). Bugbot reads the file as free prose, so a check's `severity` and `tools` have no equivalent there — they are not written and do not come back on import, and neither is `description` whenever the check also has a body. Project scope only: Bugbot reads repository files and there is no user-level instruction file. Because Bugbot only sees the file when it is **committed**, the derived `.gitignore` deliberately does not ignore `.cursor/BUGBOT.md` (Rovo Dev's `.rovodev/.review-agent.md` and Factory Droid's `.factory/skills/review-guidelines/SKILL.md` get the same treatment) — commit the generated file for the reviewer to pick it up. Example output:\n\n```md\n<!-- rulesync:check:security -->\n\n## security\n\nReview the diff for injection vulnerabilities.\n```\n\nOn import the markers split the file back into one check per section, each with `targets: [\"*\"]` because Bugbot instructions are plain prose that applies anywhere. Content sitting ahead of the first marker — and a hand-written `BUGBOT.md` with no markers at all — is imported as a single `bugbot` check, so nothing in the file is dropped. A check body that contains a marker line of its own (a quoted rulesync doc fragment, say) is written as `<!-- rulesync:literal-check:… -->` and restored on import, so it cannot split the check it belongs to. Bugbot also merges nested `<dir>/.cursor/BUGBOT.md` files found while traversing upward from changed files, but rulesync check sources carry no directory-placement semantics, so only the root file is generated.\n\nGenerating checks for Cursor replaces `.cursor/BUGBOT.md`, so run `rulesync import --targets cursor --features checks` first if the repository already has a hand-written one — generation warns when it is about to replace instructions rulesync did not write. Deletion is guarded: a `BUGBOT.md` holding anything rulesync did not write — no marker at all, or hand-written text ahead of the first marker — is never removed, so dropping the last check that targets Cursor takes rulesync's own output with it and nothing else.\n\nFor Rovo Dev CLI, checks are [code-review custom instructions](https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/), and Rovo Dev reads one plain-Markdown file rather than a file per check — so every check targeting Rovo Dev collapses into `.rovodev/.review-agent.md` (note the leading dot in the file name). The file takes **no frontmatter**. Everything else works exactly as it does for Cursor Bugbot above, because the two surfaces are the same shape: one marked-up section per check, `severity`/`tools` dropped, `description` used only when the body is empty, markers splitting the file back on import (with a hand-written file importing as a single `review-agent` check), the same `<!-- rulesync:literal-check:… -->` escaping, the same replace-and-warn on generate, and the same deletion guard for a file holding anything rulesync did not write. Project scope only — these are per-repository review instructions and Rovo Dev documents no user-level equivalent, which is the opposite of the Rovo Dev permissions surface (global only).\n\nFor Kilo Code, checks are the [repository guidance file](https://kilo.ai/docs/automate/code-reviews) that Kilo Code Reviews reads, so every check targeting Kilo collapses into the repository-root `REVIEW.md`. It works like Rovo Dev's file above — one marked-up section per check, no frontmatter, `severity`/`tools` dropped, `description` used only when the body is empty, markers splitting the file back on import (a hand-written file imports as a single `review` check), and the same deletion guard — with one difference: `REVIEW.md` is a generic root-level name other reviewers may read too, so a hand-written `REVIEW.md` is **left untouched** with a warning instead of being replaced (import it first, then delete it, to let rulesync own it). Kilo reads the file from the PR/MR **base branch**, so the derived `.gitignore` does not ignore it — commit it — and the reviewer only uses it once **Use REVIEW.md** is enabled in the Code Reviews settings of the Kilo web app, which rulesync cannot toggle. Project scope only.\n\nFor Qwen Code, checks are the [project review rules](https://qwenlm.github.io/qwen-code-docs/en/users/features/code-review/) that `/review` injects into its review agents as additional criteria, and Qwen Code reads one plain-Markdown file rather than a file per check — so every check targeting Qwen Code collapses into `.qwen/review-rules.md`. The file takes **no frontmatter**, and everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, `severity`/`tools` dropped, `description` used only when the body is empty, a hand-written file importing as a single `review-rules` check, the same escaping, replace-and-warn on generate, and the same deletion guard). `/review` reads `.qwen/review-rules.md` ahead of its fallbacks (`.github/copilot-instructions.md`, then a `## Code Review` section in `AGENTS.md` / `QWEN.md`), and for PR reviews it reads the rules from the **base branch**, so the derived `.gitignore` deliberately does not ignore the file — commit it, and the rules take effect for pull requests once they reach the base branch. Project scope only: there is no user-level rules file. (Qwen Code's `review.*` settings are reviewer policy honored only in user/system settings, not review criteria, so global checks are not written there.)\n\nFor Factory Droid, checks are its [repository-specific review guidelines](https://docs.factory.ai/software-factory/code-review-ci). Factory's automated code review has no dedicated instruction file: it reads a skill named `review-guidelines` and injects it into every review run, so every check targeting Factory Droid collapses into `.factory/skills/review-guidelines/SKILL.md`. The file is plain Markdown with **no frontmatter**, matching Factory's documented example — and frontmatter would be self-defeating anyway, since anything ahead of the first marker counts as hand-written text. Everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, `severity`/`tools` dropped, the same escaping, the same deletion guard), with a hand-written file importing as a single `review-guidelines` check (its YAML frontmatter, if it has any, is skill metadata rather than review prose and is dropped on the way in). Project scope only: the reviewer runs against a repository and reads the file out of it, so there is no user-level equivalent to write. Because the output lives inside the `.factory/skills/` tree the `skills` feature also writes, the derived `.gitignore` ignores that tree as `**/.factory/skills/**` and re-includes this one file — git cannot un-ignore a path inside an ignored directory, so the directory pattern has to be the broader one. Re-run `rulesync gitignore` after upgrading: an earlier rulesync wrote the directory itself as `**/.factory/skills/`, and git never descends into an ignored directory, so that one line left in place would keep the re-include from working and the reviewer would never see the generated file. The command takes the old spelling out for you, wherever in the file it sits. A `review-guidelines` skill you authored yourself collides with the generated file. Generation does not replace it: a file holding anything ahead of the first generated marker is left exactly as it is, no Factory Droid checks are written at all for that run, and a warning names the file — the path has a single owner rather than a merge rule, and a file here may be an ordinary skill written for Droid's skill loader rather than review prose, so rulesync does not rewrite it (this is stricter than Cursor Bugbot's replace-and-warn, whose path only the reviewer reads). Import it with `rulesync import --targets factorydroid --features checks` and then delete the file: importing copies the text into `.rulesync/checks/` but leaves the file itself alone, so generation stays blocked until it is gone, and the next generate writes the path back from `.rulesync/checks/`. Delete it outright if you no longer want it, or rename the directory if what it holds is an ordinary skill rather than review guidelines. The `skills` feature leaves this path alone in every direction, whatever the file holds: a project-scoped `review-guidelines` skill is never imported as a skill, never swept as an orphan skill directory by `generate --delete`, and never generated into `.factory/skills/review-guidelines/` in the first place — a rulesync skill of that name is skipped for Factory Droid with a warning, so rename it if you want it generated. Global scope is unaffected, since checks has no user-level output: `~/.factory/skills/review-guidelines/` is an ordinary skill. Deleting the file when the last check targeting Factory Droid goes away is still guarded: it is removed only when it holds nothing but generated sections. Note that this file is **committed**, unlike the rest of `.factory/skills/`: Factory's reviewer reads it out of the checked-out repository, so whatever it contains is injected into every review run — review it as you would any other committed instruction file, in particular when it came from `rulesync fetch`.\n\nFor AugmentCode, checks are [Augment Code Review guidelines](https://docs.augmentcode.com/codereview/review-guidelines), which live in one YAML file at `.augment/code_review_guidelines.yaml` rather than in a file per check. Augment groups rules into named **areas**, each with a `description`, the `globs` it applies to, and a list of `rules` — every rule an `id` / `description` / `severity` triple, all of them required. Rulesync maps one check onto one rule: the body becomes the rule's `description` (the check's `description` is used when the body is empty, and the file stem when neither is set), and the rule lands in an area of its own, keyed by the check's file stem. An `augmentcode` frontmatter block moves it: `area` groups several checks under one key, with `areaDescription` and `globs` taken from the first check to name that area (`globs` defaults to `[\"**\"]`, matching Augment's own example; an authored empty list is kept as written, since an area matching nothing is a narrower statement than the catch-all, not an absent value), and `id` overrides the rule id. An authored `area` is used verbatim — Augment's own documented example keys an area `memory_safety`, and rewriting the underscores would leave the original area behind while a second one appeared beside it. Only the file-stem default is slugified, since that has to become a legal key from an arbitrary file name. Rule ids are kept distinct because Augment reports findings by id: two same-named checks in different subdirectories become `security` and `security-2`, and a generated id also steps aside for one a preserved hand-written area already uses. Example:\n\n```md\n---\ntargets: [\"augmentcode\"]\nseverity: high\naugmentcode:\n area: databases\n areaDescription: \"Data and Database related rules\"\n globs: [\"db/**\"]\n id: \"no_pii_in_bigquery\"\n---\n\nNever store PII data in BigQuery tables.\n```\n\n**Severity is lossy in one direction.** Augment's scale is `high` / `medium` / `low` with no band above `high`, so canonical `critical` is written as `high` and imports back as `high` — the canonical value is not recoverable from Augment's file alone. A check with no `severity` emits `medium`, since the field cannot be omitted: `high` would push every unannotated check past the ones deliberately marked `medium`, and `low` would bury them.\n\nGeneration **merges** rather than replaces, because Augment's documentation tells users to hand-write this file. Only the areas the current check set claims are rewritten — and a claimed area is replaced as a whole, so a field you hand-added inside one Rulesync regenerates does not survive. Every other area, the `file_paths_to_ignore` list, and any key Augment adds later are left untouched. `file_paths_to_ignore` is recognized and preserved but never authored or imported — the canonical check model has no ignore surface, and adding one is a separate question. The cost of merging is that rulesync cannot tell its own leftovers from a hand-written area: renaming a check strands the area under the old key, and when checks remain but none target AugmentCode the existing areas are left in place with a warning rather than guessed at. For the same reason the file is never deleted once it exists — unlike the Markdown surfaces, YAML carries no marker saying which text is rulesync's, since a rewrite drops comments and an unknown top-level key risks Augment's own parser.\n\nOn import, each rule becomes its own check (an area of three rules is three checks, not one), carrying the area key, description and globs back in its `augmentcode` block so the next generate regroups them exactly where they were. A rule missing `id` or `description` is left in the YAML rather than imported, and a rule id repeated across two areas is suffixed so the second check does not overwrite the first. Project scope only — the reviewer reads the file from the committed repository, and Augment documents no user-level equivalent.\n\nFor GitLab Duo CLI, checks are [custom review instructions](https://docs.gitlab.com/user/duo_agent_platform/customize/review_instructions/) for the Code Review Flow, which live in one YAML file at `.gitlab/duo/mr-review-instructions.yaml` (project scope only; the review reads it from the repository, so the file is committed rather than gitignored). The file is a list of instruction groups, each a `name`, optional `fileFilters` globs, and the `instructions` text, and every check targeting GitLab Duo becomes one group: `name` is the check's file name (without `.md`) unless `gitlabduo.name` sets it — GitLab quotes it in review comments as `According to custom instructions in '<name>'` — `fileFilters` comes from `gitlabduo.fileFilters` (omitted means every file), and the body is the `instructions` (falling back to `description`). GitLab has no severity or tool fields, so `severity` and `tools` are not written.\n\n```md\n---\ntargets: [\"gitlabduo\"]\ngitlabduo:\n name: \"Ruby Style Guide\"\n fileFilters: [\"*.rb\", \"lib/**/*.rb\", \"!spec/**/*.rb\"]\n---\n\nFollow the Ruby style guide and flag N+1 queries.\n```\n\nAs for AugmentCode, generation **merges** rather than replaces, because GitLab documents the file as hand-written: a group whose `name` a check claims is replaced in place, other groups and top-level keys are left untouched, and new groups are appended. The file is re-serialized on write, so YAML comments and quoting or block-scalar styles in it are not preserved. Renaming a check therefore leaves the group under the old name behind, and with no GitLab-targeting check at all the file is left alone with a warning; an existing file is never deleted. On import, each group with a `name` and `instructions` becomes one check carrying its `name` and `fileFilters` in the `gitlabduo` block.\n\nFor Hermes Agent, Rulesync writes project-local JSON specs under `.hermes/plugins/rulesync-checks/checks/` and a `rulesync-checks` plugin beside them. Its one-shot [`pre_verify` hook](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/#pre-verify) fires only for coding turns with changed paths and `attempt == 0`, then asks Hermes to run all configured checks before finishing. `tools` is preserved as advisory guidance because Hermes does not enforce an Amp-style per-check tool allowlist. Run Hermes with the project plugin explicitly trusted for that invocation:\n\n```sh\nHERMES_ENABLE_PROJECT_PLUGINS=1 hermes\n```\n\nRulesync adds `rulesync-checks` to `plugins.enabled` in `$HERMES_HOME/config.yaml` but deliberately leaves `$HERMES_HOME/.env` unchanged, preserving Hermes's global trust boundary. Existing plugin configuration is preserved; an explicit `plugins.disabled` conflict fails generation.\n\nFor Takt, checks are **quality gates**, and they live in the `workflow_overrides` block of the shared `.takt/config.yaml` (project) / `~/.takt/config.yaml` (global) rather than in files of their own — so every check targeting Takt collapses into that one file. A check becomes one gate: by default a **string gate**, the body text, which Takt injects into the agent step prompt as a completion directive (the `description` is used when the body is empty, and the file stem when neither is set); with `command` in the check's `takt` frontmatter block, a **command gate** (`{type: command, name, command, cwd, timeout_ms}`), which Takt runs after the step and fails on a non-zero exit code. `name` defaults to the file stem so Takt's logs identify the gate. `name`, `cwd` and `timeout_ms` belong to a command gate, so they are ignored on a check that states no `command`. `steps` and `personas` in that block scope a gate to named workflow steps or personas (`workflow_overrides.steps.<step>.quality_gates`); an unscoped gate applies everywhere, and a gate naming both is written to both. `quality_gates_edit_only` is a property of the block as a whole, so one check setting it turns it on for all of them. It reaches only the unscoped gates — Takt runs a `steps`/`personas`-scoped gate whether or not the step may edit files — so the reach it narrows is the other checks' unscoped gates, which is warned about when there are any. Takt gates carry no severity or tool allowlist, so a check's `severity` and `tools` are not written and do not come back on import. Takt merges quality gates additively and dedupes them (project over global over the workflow YAML's own gates). Example:\n\n```md\n---\ntargets: [\"takt\"]\ntakt:\n command: ./.takt/quality-gates/check.sh # omit for a string gate\n timeout_ms: 300000\n steps: [\"review\"] # (optional) scope to named workflow steps\n personas: [\"coder\"] # (optional) scope to named personas\n---\n```\n\nA command gate's `command` is run by Takt without any policy switch — Takt's default-deny `workflow_command_gates.custom_scripts` policy applies to gates declared in workflow YAML, not to these — so read the frontmatter of any check you obtain with `rulesync fetch` before generating. It is not unconditional in the other direction either: since Takt 0.62.0 command gates run after rule resolution and before the transition is applied, and a workflow rule with `command_gates: skip` applies its transition without running them. The body of a check that carries a `command` is not used. `workflow_overrides` is owned by the checks feature: it is rewritten from `.rulesync/checks/` on every generate, so a gate deleted there disappears from `config.yaml` too, while every other key of the file is preserved and the file is never deleted. When checks remain but none of them target Takt — every one names other tools — the block is retracted with a warning, whether an earlier generate or a hand edit put it there; that is what owning the key means, so author gates as checks rather than in `config.yaml`. A project with no `config.yaml` does not get one. Emptying `.rulesync/checks/` altogether is different: the feature has no source to generate from, so nothing runs and the gates already in `config.yaml` stay. Delete the block by hand in that case — a command gate left behind keeps running on every step it applies to (unless a rule skips it). On import, each gate becomes its own check file, named from the gate text or the command gate's `name`. A string gate is prose that applies anywhere, so it imports with `targets: [\"*\"]` like an Amp check; a command gate imports as `targets: [\"takt\"]`, since its body is empty and would generate an empty check for every other tool. A gate scoped to both a step and a persona becomes two checks, and a command gate carrying a field of the wrong type is left in `config.yaml` rather than imported. The default-deny `workflow_command_gates.custom_scripts` policy is **not** written here — Takt validates it against gates declared in workflow YAML, not against these, and it is authorable through the `takt` block of `.rulesync/permissions.*`, which owns the security policies. See the [Takt workflows docs](https://github.com/nrslib/takt/blob/main/docs/workflows.md).\n\nThe emitted Amp frontmatter is derived from the source as follows:\n\n| Amp field | Source |\n| ------------------ | -------------------------------------------------------- |\n| `name` | the source file basename without `.md` (required by Amp) |\n| `description` | `description` |\n| `severity-default` | `severity` |\n| `tools` | `tools` |\n\nThe frontmatter schema is loose, so extra Amp-specific keys survive a generate/import round-trip (except keys that collide with a rulesync tool-target name such as `cursor` — those are treated as tool-scoped sections and are not re-emitted). A tool-scoped section (e.g. `amp: { \"severity-default\": \"critical\" }`) overrides the canonical values for that tool — the tool-specific value takes precedence, and the section itself is not emitted (except `name`, which always comes from the file name). On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.\n\n> **Amp runner:** The supported way to run checks is Amp's official `@amp/review-checks` plugin (`amp plugins add @amp/review-checks`); the `amp review` command is deprecated, and existing checks in this format continue to apply. Amp also discovers subtree-scoped checks (e.g. `api/.agents/checks/`, applied to changed files in that directory and its children), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the [Amp review docs](https://ampcode.com/docs/review).\n\n> **Goose note (checks):** `goose review` discovers repository checks in `.agents/checks/*.md`, the same cross-tool directory the `amp` target writes (Goose's `tools` and `severity-default` fields exist for parity with Amp's), so the `goose` checks target is **global-only** and project scope is left to `amp`: `rulesync generate --targets goose --global` writes `~/.config/goose/checks/<name>.md`, which Goose reads before `~/.config/agents/checks/`. The frontmatter is Amp's (`name` from the file name, `description`, `severity-default` from `severity`, `tools`) plus Goose's `model` and `turn-limit` (a non-negative integer), which are authored in a `goose:` section; import puts them back into that section, so they do not reach other targets. See [`crates/goose/src/checks/mod.rs`](https://github.com/aaif-goose/goose/blob/main/crates/goose/src/checks/mod.rs).\n\n> **JetBrains AI Assistant note:** the `aiassistant` target does not generate checks. [Self-Review with AI](https://www.jetbrains.com/help/ai-assistant/ai-in-vcs-integration.html#define-guidelines-for-self-review) reads a Markdown guidelines file only from the path entered in **Settings | Tools | AI Assistant | Project Settings | Path to rules for AI Self-Review** ([project settings reference](https://www.jetbrains.com/help/ai-assistant/settings-reference-project-settings.html)). The setting has no default path, so AI Assistant would not pick up any file Rulesync wrote until each user configured it in the IDE. To reuse your review guidance, point that setting at a plain Markdown file you keep in the repository (a `.rulesync/checks/` source would also pass its YAML frontmatter to the reviewer as text).\n\n## `.rulesync/skills/*/SKILL.md`\n\nExample:\n\n```md\n---\nname: example-skill # skill name\ndescription: >- # skill description\n A sample skill that demonstrates the skill format\ntargets: [\"*\"] # * = all, or specific tools\n# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,\n# copilotcli, crush, cursor, zed, pi, omp, qwencode, vibe, grokcli, factorydroid, dsh, reasonix,\n# commandcode, and lettacode. Any of those tool sections can override it by setting their own `disable-model-invocation`\n# value below. devin also reads this root value (true maps onto a user-only\n# `triggers` list); it has no section key of the same name, but devin.triggers\n# overrides it. codexcli also reads this root value (true maps onto\n# `policy.allow_implicit_invocation: false` in the agents/openai.yaml sidecar);\n# codexcli.policy.allow_implicit_invocation overrides it.\ndisable-model-invocation: true\n# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,\n# copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid, dsh, reasonix, commandcode,\n# and lettacode.\n# Any of those tool sections can override it by setting their own `user-invocable` value below.\n# devin also reads this root value (false maps onto a model-only `triggers`\n# list); it has no section key of the same name, but devin.triggers overrides it.\n# antigravity-ide, antigravity-cli and antigravity-plugin also read this root value\n# (false maps onto `disable-slash-command: true`); their section's\n# `disable-slash-command` overrides it.\nuser-invocable: false\n# (optional) shared defaults for the three Agent Skills standard packaging fields.\n# Each applies to every tool that models the field, and any of those tool sections\n# can override it by setting the same key below (a section value replaces the root\n# value outright; the two are never merged). Targets that normalize the field —\n# the Agent Skills writers (agentsskills, hermesagent, agentsmd) flatten an object\n# `compatibility` to a string and stringify `metadata` values — apply the same\n# normalization to a root value as to a section value.\n# `license`: claudecode, opencode, mimocode, kilo, kiro, deepagents, copilot, copilotcli, crush,\n# pi, omp, replit, rovodev, factorydroid, agentsskills (plus hermesagent and agentsmd), vibe,\n# zcode, commandcode, grokcli.\nlicense: MIT\n# `compatibility`: the same tools minus copilot, copilotcli and zcode. A free-form string\n# per the Agent Skills spec (1–500 chars); the object form is also accepted.\ncompatibility: \"Requires git and jq\"\n# `metadata`: the same tools as `compatibility`, plus cursor, zcode, dsh, antigravity-ide,\n# antigravity-cli and antigravity-plugin (Antigravity CLI renders `metadata.icon`).\nmetadata:\n author: example-org\nclaudecode: # for claudecode-specific parameters\n model: sonnet # opus, sonnet, haiku, or any string\n when_to_use: When the user asks to review a PR # (optional) extra trigger context appended to description\n allowed-tools: # (optional) tools usable without asking; accepts a string or a list\n - \"Bash\"\n - \"Read\"\n - \"Write\"\n - \"Grep\"\n disallowed-tools: # (optional) removes these tools while the skill is active (string or list)\n - \"WebFetch\"\n effort: high # (optional) effort while active: low | medium | high | xhigh | max\n argument-hint: \"[pr-number]\" # (optional) autocomplete hint for expected arguments\n arguments: # (optional) named positional arguments for $name substitution (string or list)\n - \"pr_number\"\n context: fork # (optional) set to \"fork\" to run the skill in a forked subagent context\n agent: code-reviewer # (optional) subagent type to use when context: fork\n background: false # (optional, context: fork only) wait for the forked subagent in the invoking turn instead of backgrounding it (default true)\n shell: bash # (optional) shell for ! command blocks: bash (default) or powershell\n hooks: # (optional) hooks scoped to the skill's lifecycle (free-form per the Claude Code docs)\n PreToolUse:\n - matcher: \"Bash\"\n disable-model-invocation: true # (optional) disable model invocation for this skill\n user-invocable: false # (optional) hide from the / menu while keeping model access\n scheduled-task: true # (optional) emit to ~/.claude/scheduled-tasks/<name>/SKILL.md (global scope only) instead of .claude/skills/<name>/SKILL.md\n # paths (optional) limits auto-activation to matching globs. Accepts a\n # comma-separated string, e.g. paths: \"src/**/*.ts,test/**/*.ts\", or a list:\n paths:\n - \"src/**/*.ts\"\n - \"test/**/*.ts\"\n # Claude Code accepts the three Agent Skills standard fields below but acts on\n # none of them; they matter for claude.ai skill uploads, the Skills API, and\n # packaging with package_skill.py.\n license: Apache-2.0 # (optional) license covering the skill\n compatibility: Requires Node.js 22 or later # (optional) environment requirements, up to 500 characters\n metadata: # (optional) free-form map for your own tooling; a non-map value is dropped by Claude Code\n catalog: internal\ncodexcli: # for codexcli-specific parameters\n short-description: A brief user-facing description\n # The following sections are emitted to the agents/openai.yaml sidecar next to SKILL.md.\n # See https://developers.openai.com/codex/skills.md\n interface: # (optional) UI metadata\n display_name: Example Skill\n short_description: A brief user-facing description\n default_prompt: Do the thing\n policy: # (optional) invocation policy\n allow_implicit_invocation: false # only invoke explicitly via $skill\n dependencies: # (optional) tool dependencies\n tools:\n - type: mcp\n value: example\n description: Example MCP tool\npi: # for Pi Coding Agent-specific parameters (optional; Agent Skills standard)\n # Authored either as a canonical list or as the spec's space-delimited string;\n # emitted to SKILL.md as the string, and imported back as the list.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n disable-model-invocation: true # (optional) disable model invocation for this skill\n license: MIT # (optional)\n compatibility: \"Requires git and jq\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\nreplit: # for Replit Agent-specific parameters (optional; Agent Skills standard)\n # Authored either as a canonical list or as the spec's space-separated string;\n # emitted to SKILL.md as the string, and imported back as the list.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n license: MIT # (optional)\n compatibility: \"Requires git and docker\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\ndeepagents: # for deepagents-cli (dcode)-specific parameters (optional; Agent Skills standard)\n # Authored as a canonical list; emitted to SKILL.md as a space-delimited string\n # (e.g. \"Bash Read\") because dcode rejects a YAML list at runtime.\n allowed-tools:\n - \"Bash\"\n - \"Read\"\n license: MIT # (optional)\n compatibility: # (optional) free-form compatibility metadata\n deepagents-version: \">=0.1.0\"\n metadata: # (optional) free-form metadata\n author: rulesync\nopencode: # for OpenCode-specific parameters (optional)\n license: MIT # (optional)\n compatibility: # (optional) free-form compatibility metadata\n opencode-version: \">=1.16.0\"\n metadata: # (optional) free-form metadata\n author: rulesync\n allowed-tools: # (optional) Anthropic-spec passthrough; OpenCode ignores unknown fields\n - \"Bash\"\n - \"Read\"\nkilo: # for Kilo Code-specific parameters (optional)\n license: MIT # (optional)\n compatibility: \"Requires git\" # (optional) free-form string (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\n allowed-tools: # (optional) backward-compat passthrough; not part of Kilo's official SKILL.md frontmatter\n - \"Bash\"\n - \"Read\"\nkiro: # for Kiro-specific parameters (optional; project .kiro/skills/, global ~/.kiro/skills/)\n license: MIT # (optional)\n compatibility: \"Requires network access\" # (optional) free-form string (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata\n author: rulesync\n # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and\n # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and\n # `description` are the exception: they have canonical homes at the top level.\nkimi-code: # for Kimi Code-specific parameters (optional; project/global .kimi-code/skills/)\n type: inline # (optional) prompt, inline, or flow\n whenToUse: \"When reviewing pull requests\" # (optional) model invocation hint\n disableModelInvocation: false # (optional) prevent automatic model invocation\n arguments: [\"pull_request\"] # (optional) named arguments, also accepts a whitespace-separated string\nagentsskills: # for the Agent Skills standard target (optional; supports project + global ~/.agents/skills/)\n # `license`, `compatibility` and `metadata` fall back to the root-level values above\n # when omitted here; a value set here wins for this target only.\n license: MIT # (optional)\n compatibility: \"Requires Python 3.14+ and uv\" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)\n metadata: # (optional) free-form metadata (spec-recommended place for skill versioning)\n version: \"1.0.0\"\n allowed-tools: \"shell\" # (optional, experimental) space-separated string or list\namp: # for Amp-specific parameters (optional; project .agents/skills/, global ~/.config/agents/skills/)\n # Amp reads the open Agent Skills standard and documents no frontmatter field beyond\n # `name`/`description`, so this section exists only to carry keys a hand-written SKILL.md adds:\n # they are imported into it and written back to the top level of the generated file instead of\n # being erased on regeneration. `name` and `description` are the exception — they have canonical\n # homes at the top level and a section value of either is ignored.\ncopilot: # for GitHub Copilot-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)\n license: MIT # (optional)\n allowed-tools: \"shell\" # (optional) tools pre-approved without per-use confirmation\n argument-hint: \"[message]\" # (optional) hint shown for the skill's expected arguments\n user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME\n disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own\n context: fork # (optional, experimental) run the skill in a forked session (VS Code 1.118+)\n # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and\n # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and\n # `description` are the exception: they have canonical homes at the top level. Like the modeled\n # fields below, such a key rides one section only, so the shared-path caveat that follows applies\n # to it too.\n # `copilot` and `copilotcli` write the same SKILL.md path at both scopes, so with both targets\n # enabled the one generated last wins — and that is the order the targets are listed in, so which\n # section decides the file is not fixed. Set the value in both sections (or, for the two invocation\n # gates, in the shared top-level fields) whenever you generate for both. `context` has no\n # `copilotcli` counterpart, so it survives only when `copilot` is generated last.\ncopilotcli: # for GitHub Copilot CLI-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)\n license: MIT # (optional)\n allowed-tools: \"shell\" # (optional) tools pre-approved without per-use confirmation\n argument-hint: \"[message]\" # (optional) hint shown for the skill's expected arguments\n user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME\n disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own\n # As in the `copilot` section, any other frontmatter key found in a hand-written SKILL.md is\n # imported here and written back out.\nrovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard)\n allowed-tools: \"grep bash\" # (optional) space-separated string (a YAML list is also accepted)\n license: MIT # (optional)\n compatibility: \"Requires Python 3.14+ and uv\" # (optional) free-form string (object form also accepted)\n metadata: # (optional) free-form metadata\n author: rulesync\nzed: # for Zed-specific parameters (optional)\n disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill\ncursor: # for Cursor-specific parameters (optional)\n paths: # (optional) glob patterns (string or list) scoping the skill to matching files\n - \"src/**/*.ts\"\n disable-model-invocation: true # (optional) only include the skill when invoked via /skill-name\n user-invocable: false # (optional) hide from / autocomplete and typed /skill-name, keep model access\n icon: rocket # (optional) Custom Modes badge icon (code, terminal, bug, git-branch, book-open, beaker, shield, rocket); unknown values fall back to Cursor's default badge\n color: purple # (optional) Custom Modes badge color (default, green, cyan, blue, purple, magenta, orange, yellow, red, brand)\n # On import, the legacy Cursor `globs` field is read as a fallback for `paths` (and dropped when `paths` is also set); generate always writes `paths`.\n # A `.cursor/skills/` subfolder without its own SKILL.md (a category folder grouping skills) is skipped on import and left in place by `generate --delete`; skills nested inside it are not imported.\n metadata: # (optional) free-form metadata\n author: rulesync\nfactorydroid: # for Factory Droid-specific parameters (optional)\n disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill\n user-invocable: false # (optional) hide from the slash-command menu, keep model access\n enabled: false # (optional, default true) keep the skill on disk but stop Droid loading it\n allowed-tools: \"Read Execute\" # (optional) tools the skill is designed to use (string or list)\n # Droid documents the four packaging fields below without a type and never validates\n # them, so rulesync carries whatever value they hold through in both directions.\n license: MIT # (optional) license metadata for shared skills\n compatibility: droid # (optional) compatibility metadata for catalogs, plugins, or team tooling\n metadata: # (optional) structured metadata for your own tooling\n owner: platform-team\n version: \"1.0.0\" # (optional) version string for shared or packaged skills — quote it, since\n # an unquoted 1.0 is a YAML number and is emitted back as `version: 1`\n # `name` and `description` are the exception: the top-level values always win over a\n # value of the same key inside this section.\n # As in the `kiro` section, any other frontmatter key found in a hand-written SKILL.md is\n # imported here and written back out.\ntakt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit)\n name: \"renamed-stem\" # (optional) override the emitted filename stem (no path separators or \"..\")\n extends: \"base\" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)\ndevin: # for Devin-specific parameters (optional; project .devin/skills/, global ~/.config/devin/skills/)\n argument-hint: \"[environment]\" # (optional) hint shown after the slash-command name\n model: \"fast\" # (optional) model override while the skill runs\n subagent: true # (optional) run the skill in a subagent (string or boolean per Devin's docs)\n agent: \"deployer\" # (optional) named agent profile to run the skill with\n allowed-tools: # (optional) tools available while the skill runs (string or list)\n - \"Bash(git status:*)\"\n permissions: {} # (optional) auto-approval rules applied while the skill runs (load-bearing since Devin CLI v3000.1.23)\n triggers: [\"user\"] # (optional) invocation gating; omitted = user + model. The shared disable-model-invocation / user-invocable flags map onto this when unset.\nqwencode: # for Qwen Code-specific parameters (optional; project .qwen/skills/, global ~/.qwen/skills/)\n priority: 10 # (optional) higher values appear earlier in /skills listings\n paths: # (optional) glob patterns gating model discovery to matching files (a scalar is coerced to the array Qwen Code requires)\n - \"src/**/*.ts\"\n user-invocable: false # (optional) hide from slash-command invocation, keep model access\n disable-model-invocation: true # (optional) hide from the model but allow direct user invocation\n allowedTools: # (optional) permissions.allow-syntax rules auto-approved while the skill is active\n - \"Shell(git status:*)\"\n model: \"fast\" # (optional) model override while the skill runs (model id, fast, authType:modelId, inherit)\n hooks: {} # (optional) session-scoped hooks registered while the skill runs (settings.json shape)\n when_to_use: \"Use when deploying\" # (optional) invocation guidance surfaced in the SkillTool description\n argument-hint: \"[environment]\" # (optional) hint shown after the slash-command name in completion\ngrokcli: # for Grok CLI-specific parameters (optional)\n user-invocable: false # (optional) hide from the skill tool, keep model access\n disable-model-invocation: true # (optional) block auto-invocation, keep the slash command\n paths: [\"src/api/**\", \"*.sql\"] # (optional) gitignore globs; the skill stays hidden until a tool touches a matching file (a comma-separated string is also accepted)\n when-to-use: \"When the user asks to deploy\" # (optional) trigger phrases kept separate from description (Grok also reads `when_to_use`; either spelling is written as authored)\n allowed-tools: [\"Bash\", \"Read\"] # (optional) tools the skill uses, a list or a comma- or space-separated string\n argument-hint: \"[environment]\" # (optional) hint shown in the slash-command autocomplete\n model: \"grok-code-fast-1\" # (optional) model override for running the skill\n effort: \"high\" # (optional) reasoning-effort override\n metadata: # (optional) overrides the root value outright; Grok displays `author` and `short-description`\n author: example-org\n # Any other key (e.g. `origin`) is written verbatim, and on import every key besides\n # name/description comes back into this section.\ndsh: # for DeepSeek Harness-specific parameters (optional; project .dsh/skills/, global ~/.dsh/skills/)\n whenToUse: \"When the user asks to review a PR\" # (optional) extra trigger-timing context\n metadata: # (optional) free-form metadata; overrides the root value outright\n author: example-org\n user-invocable: false # (optional) hide from user invocation, keep model access\n disable-model-invocation: true # (optional) block auto-invocation\nreasonix: # for Reasonix-specific parameters (optional; project .reasonix/skills/, global ~/.reasonix/skills/)\n user-invocable: false # (optional, v2) hide from the slash surface, keep model access\n disable-model-invocation: true # (optional) hide from the model; also written as `invocation: manual` for the v1 line\n invocation: manual # (optional) Reasonix's catalog switch (auto | manual); always manual when model invocation is disabled\nvibe: # for Vibe Code-specific parameters (optional)\n user-invocable: false # (optional) hide from slash-command invocation, keep model access\n disable-model-invocation: true # (optional, Vibe v2.25.5+) explicit-only: keep the slash command, block model-initiated loading\n allowed-tools: \"Bash Read\" # (optional) space-delimited or list of allowed tool names\ncline: # for Cline-specific parameters (optional; project/global .cline/skills/; keys are written into SKILL.md verbatim)\n disabled: true # (optional) Cline's own on/off toggle: hides the skill until it is re-enabled in Cline (`enabled: false` is read the same way)\nantigravity-cli: # for Antigravity CLI-specific parameters (optional; `antigravity-ide` takes the same keys. Both targets write the shared project `.agents/skills/`, so there each reads `antigravity-ide` then `antigravity-cli` merged, the CLI winning; globally each reads its own. `antigravity-plugin` reads the `antigravity-ide` section)\n disable-slash-command: true # (optional, CLI v1.1.12+) hide from the / menu, keep model access; overrides the root user-invocable\n metadata: # (optional) free-form metadata; overrides the root value outright\n icon: \"📦\" # (optional, CLI v1.1.20+) emoji shown in the /skills catalog and slash-command autocompletion\n---\n\nThis is the skill body content.\n\nYou can provide instructions, context, or any information that helps the AI agent understand and execute this skill effectively.\n\nThe skill can include:\n\n- Step-by-step instructions\n- Code examples\n- Best practices\n- Any relevant context\n\nSkills are directory-based and can include additional files alongside SKILL.md.\n\nWhen `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `\"*\"`. Claude Code reads scheduled tasks only from the user config directory, so the skill is written only by `rulesync generate --global`, to `~/.claude/scheduled-tasks/<name>/SKILL.md` (Rulesync always writes under `~/.claude/`; it does not follow `CLAUDE_CONFIG_DIR`). A project-scope generate warns and skips it instead of writing a copy under the project's `.claude/scheduled-tasks/` that Claude Code would never run; a project-scope generate with `--delete` removes a copy an earlier version left there. Import still reads both locations.\n```\n\n> **Supporting-file note:** every file beside `SKILL.md` in a skill directory is copied **byte for byte**, to whichever tool root the skill is generated into. Most of them are user assets — images, archives, fixtures whose CRLF line endings or missing trailing newline are deliberate — so unlike `SKILL.md`, whose body and frontmatter Rulesync composes, they get no UTF-8 round-trip, no line-ending normalization and no trailing newline appended. Change detection compares them byte for byte too, so a supporting file written by an older Rulesync (which normalized text files) or edited in place by a formatter is rewritten from the source on the next generate. A supporting file that is executable in `.rulesync/skills/<name>/` (any of the `0o111` bits set) is written executable too, with its permission bits masked to `0755` (group and world write bits are dropped, as are setuid, setgid and sticky), so a `scripts/run.sh` an agent is told to run stays runnable; a generate that finds the directory unchanged still restores the bit if something stripped it. Files without an executable bit keep the mode they already have, or the process default when newly created. A symbolic link standing where the copy should be is never chmod-ed. On Windows there is no mode to carry, and none is applied. The one exception is a supporting file Rulesync composes itself rather than carries through — Codex CLI's `agents/openai.yaml` — which is compared by parsed content so that re-indenting it does not report a change on every generate. Dot-prefixed entries count as supporting files too — the specification says a skill directory \"may contain any files and directories beyond the required `SKILL.md`\", and a hidden `.env.example` or `.config/` is content the skill needs. Some entries are never carried, whether they are reached by their own name or through a symbolic link that renames them:\n>\n> - `.git`, `.hg` and `.svn` — a nested repository, whose tracked files are copied but whose history is not. Rulesync warns when it skips a top-level `.git`.\n> - `.DS_Store` — the macOS Finder's index.\n> - Credential stores and credential-shaped files: anything under `.ssh/`, `.aws/` or `.gnupg/`, plus `.npmrc`, `.netrc`, `.git-credentials`, `.pgpass`, `.pypirc`, `.htpasswd`, `.dockercfg`, `.envrc`, `.docker/config.json`, `.kube/config`, `.config/gh/hosts.yml`, `.config/gcloud/credentials.db`, `.gem/credentials`, `gcloud/application_default_credentials.json`, `.codex/auth.json` and `.gemini/oauth_creds.json`. A credential name counts wherever it appears in the path, not only as the last segment: `.env/production` and `.netrc/machine` hold what the files of those names hold, so a directory given a credential file's name is refused with everything under it. Carrying a secret would copy it into every enabled tool root, multiplying the places it can be committed from. A link that leaves the skill directory to reach a `.config/`, `.local/`, `.azure/`, `.m2/`, `.terraform.d/`, `.docker/`, `.kube/` or `Keychains/` tree is refused entirely — those are the per-application directories of a home directory, where naming each credential file is a list always one release behind — while a `.config/` the skill ships itself is carried as ordinary content. The exemption is for a sibling: a link that goes no higher than the skill directory's own parent, so a global skill under `~/.config/agents/skills/` still shares `../_shared` there while the same skill reaching `~/.config/gcloud/` is refused like any other; a tool home such as `~/.claude/` is deliberately not on the list, because that is where the global skills this feature exists to share are kept.\n> - `.env` and every `.env.<suffix>` spelling except the template ones — the last piece of the name decides, so `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.defaults` and compound spellings such as `.env.local.example` are carried, being the case this support exists for, while `.env.local` and `.env.production` hold real values.\n> - Build, cache and virtual-environment trees: `.cache`, `.venv`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.gradle`, `.next`, `.nuxt`, `.turbo`, `.parcel-cache`, `.nyc_output` and `.terraform`. These are regenerated from the skill's own sources, and copying a `.venv` into every enabled tool root would multiply thousands of files that are not skill content.\n>\n> Names are compared case-insensitively, so `.SSH/` is excluded on the platforms that treat it as `.ssh/`, and trailing dots and spaces are trimmed first, because Windows drops them when the file lands (`.env ` becomes `.env` there). Leaving out a credential-shaped entry is reported in a warning, and so is an entry whose own name says nothing about it — a link named `assets` pointing into a `.cache/` tree; the ordinary exclusions, where the name in the skill directory is the excluded one — `.DS_Store`, build and cache trees — stay quiet.\n>\n> A skill directory is also walked within bounds, because a symbolic link in it may point anywhere: at most **12 directories deep**, at most **10,000 files**, at most **10,000 directories**, at most **200,000 entries looked at**, and at most **100MB** in total per directory, and each real directory is visited once so that a link pointing back at an ancestor collapses instead of multiplying the walk. Because only one route to a directory is walked, the route is chosen rather than left to the walk order: everything reachable without crossing a symbolic link first, then everything one link away, and so on — so a file keeps the path its `SKILL.md` refers to. When a route that the hidden-entry rule below then refuses is the one that reached a tree, the directory is walked once more with the hidden routes left out, so a tree that a fully named path also reaches is still carried under that name. Reaching any of those limits leaves files out, so it is always reported in a warning rather than silently truncating the skill; so is an entry that cannot be read at all. A route that passes through `/proc`, `/sys` or `/dev` is refused at every hop rather than only at its destination: `/proc/<pid>/fd/N` and `exe` resolve to whatever a running process holds open, so a check of the destination alone would carry another program's private key as though it were an ordinary file. Each hop's parent directory is resolved as well, since a link named `fds` pointing at `/proc/self/fd` leaves no `/proc` in the path `fds/3` to check; the hops a chain costs count against the entry limit above, so a directory of long chains cannot buy more work than a directory of files.\n\n> **`.agents/skills/` ownership note:** `.agents/skills/` is not an AGENTS.md convention — the AGENTS.md standard defines only `AGENTS.md` itself. It is the [Agent Skills](https://agentskills.io/specification) project location, which the native `agentsskills` target writes. Several targets write there — `agentsskills`, `agentsmd`, `aiassistant`, `codexcli`, `amp`, `zed`, `replit` and both Antigravity targets — because they all implement the same convention. Each native target writes its own documented frontmatter, so enabling more than one and reordering `--targets` can change which optional keys end up in the file; that is inherent to several tools sharing one path and is not specific to any of them.\n\n> The **simulated** `agentsmd` writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare `name`/`description` pair and silently drop `license`, `compatibility`, `metadata` and `allowed-tools`. It now emits exactly what `agentsskills` emits, so a simulated writer can never degrade the file a native target owns.\n\n> **AugmentCode plugin note:** The `augmentcode-plugin` target writes skills into a plugin bundle's `skills/<name>/SKILL.md` in the same format as `.augment/skills/` (project scope only), since Auggie reads plugin skills the same way; import reads only the bundle's own `skills/`, not the shared `.agents/skills/` root. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **Cross-root duplicate note:** Several targets discover skills in more than one root — a shared Agent Skills root beside the tool-specific one (`junie`, `vibe`, `kimi-code`, `rovodev`, `augmentcode`, `warp`), nested `.claude/skills/` directories (`claudecode`), or roots the tool's own config points at (`opencode`). The roots are scanned in precedence order and the first one to claim a skill name keeps it; the multi-root subagent targets follow the same rule, and for targets whose native format aggregates many subagents into one file (Roo's `.roomodes`) the same de-duplication is applied to the paths the entries fan out to. The comparison is **case-insensitive** (and Unicode normalizing), because every imported skill is written back into a single `.rulesync/skills/` tree, where `.junie/skills/my-skill` and `.agents/skills/My-Skill` are one directory on macOS and Windows — comparing the names exactly would let both through, and the shared copy, written last, would overwrite the tool-specific one, inverting the precedence the roots were ordered by. A collision that differs only in case is reported with a warning naming the ignored copy; an exact repeat stays quiet, being the ordinary overlay. Kimi Code is the exception on both counts: it identifies a skill or subagent by its frontmatter `name` folded to lower case, so two spellings are already one name upstream and the duplicate is resolved silently. **Trade-off:** the rule applies on every platform, so on a case-sensitive filesystem — where the two really are separate skills — `rulesync import` and `rulesync convert` drop the lower-precedence one instead of importing it under its own name. Rename one of them if you need both — and if the dropped copy lives in a shared `.agents/skills/` root, import `agentsskills` in the same run, so the skill still reaches `.rulesync/` and a later `agentsskills` generate does not prune it as an orphan. Slash commands are not covered by this rule yet: `augmentcode` reads both `.augment/commands/` and the shared `.agents/commands/`, but their names are still compared exactly, so `deploy.md` and `Deploy.md` are both imported and collide in `.rulesync/commands/` on a case-insensitive filesystem ([issue #2741](https://github.com/dyoshikawa/rulesync/issues/2741)).\n\n> **Claude Code nested skills note:** Claude Code v2.1.178+ also loads skills from **nested** `.claude/skills/` directories below the working directory (a skill in `apps/web/.claude/skills/` becomes available when working on files there, and a name clash with a root skill keeps both under a directory-qualified name like `apps/web:deploy`). `rulesync import --targets claudecode --features skills` discovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nested `AGENTS.md` scan; symlinks not followed) so an existing nested skill is no longer invisible. A nested directory whose reported path does not resolve inside the project is skipped with a warning rather than followed: a glob rewrites a backslash in a directory name into a path separator, so the path it reports may belong to nothing on disk, or lead out of the project — through a `..` the name also carries, or through a symlink the scan itself never follows. A reported path that stays inside the project but resolves onto something that is not a `.claude/skills` directory of its own — a dependency or build tree, or the project root itself — is skipped the same way, since reading it as a skills directory would offer every directory under it as a skill. The project-root `.claude/skills/` itself is never read as a nested root, even when it is a symlink to a directory elsewhere in the project: the directory it resolves to belongs to the root import, and the nested scan leaves it alone. On a name clash the root skill wins the import — rulesync's flat skill namespace cannot express the qualified variant. Because generation stays targeted at the project-root `.claude/skills/`, a nested skill's location-based scoping would otherwise be lost, so the import derives it: a skill found in `apps/web/.claude/skills/` gets `claudecode.paths: [\"apps/web/**\"]` written for it. Glob metacharacters in the directory names are escaped, so a Next.js-style `app/[slug]/.claude/skills/` still derives a literal match. A `paths` value the skill already declares is kept as-is — Claude Code does not document whether a nested skill's `paths` resolves against the project root or its own directory, so rulesync does not rewrite the author's glob, which means a declared value narrower than the subtree (`src/**`) is re-anchored at the project root once the skill moves there; write it subtree-qualified (`apps/web/src/**`) if that matters. Root-discovered skills get nothing added, and the derived value lands in the `claudecode:` block only — other targets with their own `paths` field (`cursor:`, `qwencode:`) are untouched. To scope a skill's _activation_ to a subtree yourself, write the `paths` frontmatter, or run a separate generate with `--output-roots <subdir>` for physical co-location.\n\n> **Claude Code `argument-hint` and import leniency note:** Claude Code's docs write `argument-hint: [issue-number]` unquoted, which YAML reads as a one-element list. On **import** (and `rulesync convert --from claudecode`), a list `argument-hint` in a `.claude/skills/*/SKILL.md` or `.claude/commands/*.md` is accepted and joined back into the bracketed string, one placeholder per entry (`[issue-number]`; `[filename, format]` becomes `[filename] [format]`; an empty list is dropped), so `claudecode.argument-hint` in `.rulesync/` is always a string and is emitted quoted on generate. The unquoted multi-placeholder form `argument-hint: [filename] [format]` is not valid YAML at all; quote it (`\"[filename] [format]\"`). Because Claude Code keeps loading the other skills when one `SKILL.md` is broken, a Claude Code skill that fails to load on import or convert (unparseable YAML, invalid frontmatter) is skipped with a warning, and the remaining skills and every later feature (hooks, permissions, …) still import. Commands are not covered by this leniency: an invalid `.claude/commands/*.md` still aborts the import.\n\n> **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section.\n\n> **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, or the root `disable-model-invocation` is `true`, Rulesync emits `.agents/skills/<name>/agents/openai.yaml` and reads it back on import. The root `disable-model-invocation: true` maps onto `policy.allow_implicit_invocation: false` (Codex then only runs the skill when invoked explicitly via `$skill`); an explicit `codexcli.policy.allow_implicit_invocation` takes precedence over the root flag, and a root `false` or absent flag adds nothing (Codex already defaults to `true`). On import, `policy.allow_implicit_invocation` is kept under `codexcli.policy` rather than promoted to the root `disable-model-invocation`, like every other tool's importer keeps its invocation flags in its own section; this regenerates the same sidecar without changing the output of other targets. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md).\n\n> **Takt-driven Codex note:** Rulesync's `codexcli` skills land in `.agents/skills/` (project) and `~/.agents/skills/` (global), but a Takt workflow driving Codex does **not** inherit repository or user skills from there by default — upstream's wording is \"TAKT workflows do not inherit repository or user Codex Skills by default\" — so skills you generated will not reach a Takt-driven Codex run unless you turn inheritance on. (`takt exec` is the documented exception: each scope defaults to inheritance when it is not explicitly configured.) The setting is `provider_options.codex.skills.repo` for the project tree and `.user` for the global one, added in Takt 0.53.0. **Where it goes depends on your Takt version.** Up to 0.55.x, and on any later version whenever `runtime.yaml` is inactive (a file carrying only `version: 1` counts as inactive and leaves the legacy resolution in place), it belongs in `provider_options` in `.takt/config.yaml` — which is also where a `takt` block in `.rulesync/permissions.*` writes it. From 0.56.0, `runtime.yaml` owns provider configuration, and while it is active **any** legacy provider setting in `config.yaml` — `provider_options` included — stops Takt with `Mixed provider configuration detected` before it runs an agent. Takt generates `~/.takt/runtime.yaml` active on first launch in a fresh environment, so a new install is in runtime mode by default; there, set the flag in `runtime.yaml` under the `options` of a profile whose provider is Codex, and keep `provider_options` out of `config.yaml`. Mind the shape when you move it: a profile's `options` is a **flat bag applying to that profile's own provider**, so the `codex` segment is dropped — `options: { skills: { repo: true } }`, not `options: { codex: { skills: { repo: true } } }`. The nested spelling is not a schema error; it is simply never read, so inheritance stays off while the config looks right. Takt 0.57.0 adds a workflow-side alternative to writing `provider_options` inline: a workflow, step, or parallel sub-step can declare `capabilities: enable-skills`, a bundled preset covering the Codex repo and user skills. Takt 0.55.0 made the same default change for Claude providers (`provider_options.claude.skills.enabled`, plus `--disable-slash-commands` on CLI-backed ones), so Rulesync-generated Claude Code skills and slash commands are off in Takt-driven sessions unless re-enabled the same way. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md) and [CHANGELOG](https://github.com/nrslib/takt/blob/main/CHANGELOG.md).\n\n> **Reasonix note:** Reasonix discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.reasonix/skills/` (project) / `~/.reasonix/skills/` (global, via `--global`). Rulesync emits the portable `name`/`description` frontmatter; the schema is loose, so an imported `SKILL.md` carrying extra keys still parses, but only the modeled keys are carried into the canonical skill (others, such as `argument-hint`, are dropped on import). The invocation flags are modeled too: the root-level `disable-model-invocation` / `user-invocable` defaults (overridable per tool under a `reasonix:` section) are written as Reasonix's own `disable-model-invocation` / `user-invocable` keys, which the v2 line reads (`internal/ext/skill/invocation_flags.go`, since v2.28.0). The legacy v1 line only knows `invocation: manual`, which hides the skill from the model's catalog while it stays callable by name — weaker than the v2 flag, which also refuses model calls. A skill whose model invocation is disabled therefore also gets `invocation: manual`, the closest v1 equivalent. `reasonix.invocation` (`auto` / `manual`) authors that catalog switch directly and is written verbatim when set, unless model invocation is disabled, which always writes `manual`. On import, the native flags land in the `reasonix:` section (Reasonix's `yes`/`no`, `on`/`off` and `1`/`0` spellings are read as booleans; like Reasonix, an unreadable `disable-model-invocation` fails closed and is read as `true`, while an unreadable `user-invocable` is ignored), and an `invocation` is kept as `reasonix.invocation` — except the `manual` that accompanies `disable-model-invocation: true`, which follows from the flag. See [invocation_flags.go](https://github.com/esengine/DeepSeek-Reasonix/blob/v2.31.0/internal/ext/skill/invocation_flags.go) and the v1 [skill.go](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.39.8/internal/skill/skill.go). See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md).\n\n> **Meta Muse Code note:** Muse Code discovers Agent Skills (`<skill-id>/SKILL.md`) under `.agents/skills/` (project) and under `$XDG_CONFIG_HOME/muse/skills` plus `~/.agents/skills` (user). Rulesync emits the shared `.agents/skills/` directory in project mode and only the XDG-default `~/.config/muse/skills` in global mode (via `--global`), so a skill is written exactly once. Muse Code's compatibility scans of repo-local `.codex/skills` and `.claude/skills` belong to other tools and are not emitted for `musecode`. Only the portable `name`/`description` frontmatter pair is modeled; the schema is loose, so extra keys on an imported `SKILL.md` survive the round-trip. See the [Muse Code extending docs](https://dev.meta.ai/docs/muse-code/extending.md).\n\n> **Zed note:** Zed discovers skills under `.agents/skills/` (project) and `~/.agents/skills/` (global) and applies rules of its own when it loads one, which rulesync reports as warnings at generate time rather than discovering them in Zed's UI: a `name` must be 1–64 characters of lowercase letters, digits and hyphens, with no leading, trailing or consecutive hyphens — a skill whose name breaks that rule \"fail[s] to load\" in Zed — and a `description` should be at most 1024 characters, past which Zed still loads the skill but warns. The skill is written either way, since the canonical skill is shared with every other target; fix the frontmatter in `.rulesync/skills/<name>/SKILL.md` to clear the warning. Zed also caps the combined size of every installed skill's name and description at 50KB and drops what does not fit from its catalog; that budget spans skills rulesync did not write, so it is not checked. Zed's own `disable-model-invocation` is authored in the `zed:` block. See the [Zed skills docs](https://zed.dev/docs/ai/skills).\n\n> **Hermes Agent note:** Hermes skills are global-only under `~/.hermes/skills/<name>/SKILL.md`. Standard Agent Skills fields (`license`, `compatibility`, and `allowed-tools`) round-trip through `agentsskills` and are normalized to the Agent Skills spec shapes described above (so `allowed-tools` is written and imported the same way as for `agentsskills`); Hermes-native fields such as `version`, `author`, `platforms`, `environments`, `required_environment_variables`, `required_credential_files`, and `metadata.hermes` round-trip through `hermesagent`. Canonical `name` and `description` always own those two frontmatter keys.\n\n> **Warp skills note:** Warp discovers skills under `.warp/skills/` (project) and `~/.warp/skills/` (global), plus the cross-tool `.agents/skills/` root at either scope — the location Warp's docs recommend. Rulesync generates to `.warp/skills/` only; the `.agents/skills/` root is import-only (declared under `importOnlySkillRoots`, like `junie` / `vibe` / `kimi-code`), scanned after `.warp/skills/` under the cross-root duplicate rule above, and never touched by `warp`-target orphan deletion — in project or global mode. Warp also lists other tools' skill roots (`.claude/skills/`, `.codex/skills/`, …) and discovers skills in subdirectories of a repository; those are not imported as `warp` skills — import the owning target instead. See the [Warp skills docs](https://docs.warp.dev/agents/capabilities/skills/).\n\n> **Kimi Code note:** Kimi Code discovers skills under `.kimi-code/skills/` (project) and `~/.kimi-code/skills/` (global), plus the shared `.agents/skills/` root at either scope. Rulesync generates the recommended directory layout (`<name>/SKILL.md`) and imports both that layout and flat `<name>.md` skills; for flat files, a missing `name` comes from the filename and a missing `description` falls back to the first non-empty body line (up to 240 characters), matching Kimi. Imported skills are written to `.rulesync/skills/<logical-name>/SKILL.md`, using the normalized logical frontmatter name rather than the source directory or filename. Duplicate precedence follows Kimi's case-insensitive logical frontmatter `name`: the Kimi-specific root takes precedence over `.agents/skills/`, and a directory skill takes precedence over a same-named flat file within one root. Shared roots are import-only and are never removed by Kimi-target orphan deletion. Besides `name`/`description`, Rulesync maps Kimi's `type`, `whenToUse`, `disableModelInvocation`, and `arguments` frontmatter through the `kimi-code:` block and preserves supporting files beside directory-layout `SKILL.md`. The shared top-level `disable-model-invocation` value supplies the Kimi flag unless the tool-specific block overrides it. See the [Kimi Code Agent Skills docs](https://moonshotai.github.io/kimi-code/en/customization/skills.html). The `kimi-code-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only); see [Plugin Packaging](../guide/plugin-packaging.md#kimi-code-plugins).\n\n> **ZCode note:** ZCode discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) and invokes them with `$`. The documented location is the user one, `~/.zcode/skills/`; the workspace scope its import dialog offers is taken to be served from the project's own `.zcode/skills/`, so rulesync writes `.zcode/skills/<name>/SKILL.md` in project mode and `~/.zcode/skills/<name>/SKILL.md` with `--global`. That project path is inferred from the import dialog and from ZCode's other workspace assets, not documented. ZCode's [Skill `SKILL.md` field reference](https://zcode.z.ai/en/docs/plugin) allowlists exactly five frontmatter keys — required `name`/`description` plus optional `when_to_use` (extra trigger-timing context), `license`, and `metadata` (an object that may carry extras such as `author`/`version`) — and states that fields outside that list \"are ignored and do not affect loading\". Rulesync models all five: `license` and `metadata` fall back to the root-level Agent Skills packaging defaults, and `when_to_use` is authored in the `zcode:` block. There is no documented `compatibility` field, so the canonical `compatibility` is not written for `zcode`. The schema is loose, so an imported `SKILL.md` carrying extra keys still parses, but only those five are carried into the canonical skill. ZCode rejects a `description` longer than 1024 characters and truncates a body past 100KB when it loads the skill; rulesync does not enforce either limit, since the canonical skill is shared with every other target. See the [ZCode skills docs](https://zcode.z.ai/en/docs/skill). The `zcode-plugin` target writes the same format into a plugin bundle's `skills/<name>/SKILL.md` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **IBM Bob note:** IBM Bob discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.bob/skills/` (project) and `~/.bob/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`; any additional keys round-trip through the `bob` frontmatter block. See the [skills docs](https://bob.ibm.com/docs/ide/features/skills).\n\n> **Tabnine CLI note:** Tabnine CLI discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.tabnine/agent/skills/` (project) and `~/.tabnine/agent/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`; any additional keys round-trip through the `tabnine` frontmatter block. See the [agent skills docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/agent-skills).\n\n> **Snowflake Cortex Code note:** Snowflake Cortex Code discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.cortex/skills/` (project) and `~/.snowflake/cortex/skills/` (global, via `--global`). The `SKILL.md` frontmatter carries `name` and `description`, plus an optional `tools` allowlist; any additional keys round-trip through the `cortexcode` frontmatter block. (Cortex Code also falls back to `.claude/skills/`, which the `claudecode` target already covers.) See the [extensibility docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#skills).\n\n> **Codewhale note:** Codewhale discovers directory-layout skills (`<name>/SKILL.md`) under `.codewhale/skills/` (project) and `~/.codewhale/skills/` (global, via `--global`). `name` and `description` are written from the canonical fields, and any Codewhale-specific frontmatter (for example `invocation`) is carried through the `codewhale` section of the rulesync skill.\n\n> **CodeBuddy Code note:** CodeBuddy Code discovers directory-layout skills (`<name>/SKILL.md`) under `.codebuddy/skills/` (project) and `~/.codebuddy/skills/` (global, via `--global`). Rulesync writes `name` and `description` from the canonical skill (same-named keys in the `codebuddy:` block are ignored), plus the shared `disable-model-invocation` / `user-invocable` defaults; the other documented keys (`allowed-tools`, `context`, `agent`, `model`, `hooks`) and any extra ones go in the `codebuddy:` block. CodeBuddy treats every SKILL.md key as optional, so on import a missing `name` falls back to the directory name and a missing `description` to an empty string. See the [CodeBuddy Code skills docs](https://www.codebuddy.ai/docs/cli/skills).\n\n> **Continue note:** Continue discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.continue/skills/` (project) and `~/.continue/skills/` (global, via `--global`). The `SKILL.md` frontmatter requires `name` (which must match the directory name) and `description`; any additional keys round-trip through the `continue` frontmatter block, and supporting files next to `SKILL.md` are carried along. (Continue also falls back to `.claude/skills/`, which the `claudecode` target already covers.) Continue has no doc page for skills yet; the [skill loader source](https://github.com/continuedev/continue/blob/5522c6f44ca0ac3528b37244818fbfa39b5af470/extensions/cli/src/util/loadMarkdownSkills.ts) is the reference.\n\n> **Command Code note:** Command Code discovers Anthropic-style directory-layout skills (`<name>/SKILL.md`) under `.commandcode/skills/` (project) and `~/.commandcode/skills/` (global, via `--global`). The `SKILL.md` frontmatter requires `name` (matching the directory name) and `description`; the documented optional keys (`license`, `compatibility`, `metadata`, `allowed-tools`, `disallowed-tools`, `argument-hint`, `when_to_use`, `disable-model-invocation`, `user-invocable`, `arguments`, `model`, `effort`) and any newer key round-trip through the `commandcode` frontmatter block, and supporting files next to `SKILL.md` are carried along. The Agent Skills packaging trio (`license`, `compatibility`, `metadata`) and the two invocation gates (`disable-model-invocation`, `user-invocable`) also fall back to the root-level rulesync value when the `commandcode` block omits them; a value in the block wins. See the [skills docs](https://commandcode.ai/docs/skills).\n\n> **Qoder note:** Qoder reads Agent Skills directories (`<name>/SKILL.md`, with the required `name` and `description` frontmatter) from `.qoder/skills/` (project) and `~/.qoder/skills/` (global, via `--global`); the Qoder IDE and the CLI read the same directories, and on a name clash the user-level skill wins. Rulesync writes them with the same Agent Skills frontmatter as the `agentsskills` target, and supporting files next to `SKILL.md` are carried along. See the [skills docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/Skills).\n\n> **Letta Code note:** Letta Code discovers directory-layout skills (`<name>/SKILL.md`) under the shared `.agents/skills/` root (project, the same directory the `agentsskills` target writes) and `~/.letta/skills/` (global, via `--global`). The `SKILL.md` frontmatter takes `name` and `description`; the optional keys Letta Code reads (`when_to_use`, `argument-hint`, `category`, `tags`, `disable-model-invocation`, `user-invocable`) round-trip through the `lettacode` frontmatter block, and supporting files next to `SKILL.md` are carried along. The two invocation gates fall back to the root-level rulesync value when the `lettacode` block omits them. See the [skills docs](https://docs.letta.com/configuration/skills/index.md).\n\n> **DeepSeek Harness note:** DeepSeek Harness scans `<projectRoot>/.dsh/skills` (project) and `~/.dsh/skills` (global, the `$DSH_HOME` default) for skills, alongside the `.agents/skills` roots the `agentsskills` target already writes, so rulesync emits `.dsh/skills/<name>/SKILL.md` in project mode and `~/.dsh/skills/<name>/SKILL.md` with `--global` and leaves the `.agents/` roots to `agentsskills`. The harness discovers only the **top level** of each root — a `<name>/SKILL.md` bundle or a flat `<name>.md` file — and deliberately ignores nested `**/SKILL.md`, which matches rulesync's output: one top-level bundle per skill, with supporting files carried inside it as plain files (rulesync never writes the flat form). Its frontmatter reads required `name`/`description` plus optional `whenToUse` (extra trigger-timing context), `metadata` (a free-form object), `disable-model-invocation` and `user-invocable`. Rulesync models all six: `metadata` and the two invocation flags fall back to the root-level shared defaults, and `whenToUse` is authored in the `dsh:` block. The harness also accepts `yes`/`no`, `on`/`off` and `1`/`0` spellings for the flags and drops the whole skill with a warning on any other value; rulesync writes and imports the canonical boolean form only, so an imported `SKILL.md` with `user-invocable: maybe` is rejected rather than silently dropped. There is no documented `license` or `compatibility` field, so those canonical fields are not written for `dsh`; the schema is loose, so an imported file carrying extra keys still parses. The harness also requires `name` to be kebab-case; rulesync does not enforce that for `dsh`, since the canonical skill is shared with every other target. See the [skill-filesystem README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) discovers Agent Skills (`<name>/SKILL.md`) under `.poolside/skills/` (project) and `~/.config/poolside/skills/` (global, via `--global` — only the XDG-default location is written). It also scans the shared `.agents/skills/` / `~/.agents/skills/` roots and other tools' skill directories, which belong to their own targets (`agentsskills` and friends), so each skill is still written exactly once. Only the required `name`/`description` pair is modeled — Pool does not enforce `allowed-tools` or `compatibility` — and the schema is loose, so extra keys survive an import. Pool only loads a skill whose directory name equals its `name`, so rulesync warns at generate time when the two differ. User-level skills are not mounted inside Pool's local sandboxes. See [Pool skills](https://docs.poolside.ai/skills).\n\n> **Goose note:** Goose discovers directory-layout skills (`<name>/SKILL.md`, with `name` and `description` frontmatter) under `.goose/skills/` (project) and `~/.config/goose/skills/` (global, via `--global` — the same `~/.config/goose/` root Rulesync uses for Goose recipes, custom agents and `config.yaml`). It also scans the shared `.agents/skills/` / `~/.agents/skills/` roots, which the `agentsskills` target writes, so generating both `goose` and `agentsskills` places each skill in two directories Goose reads. See [`skills/mod.rs`](https://github.com/aaif-goose/goose/blob/v1.53.0/crates/goose/src/skills/mod.rs).\n\n> **Crush note:** Crush auto-discovers standard Agent Skills (`<name>/SKILL.md`) under `.crush/skills/` in project mode and `~/.config/crush/skills/` in global mode (via `--global`) — the same `~/.config/crush/` root Crush's global `CRUSH.md` and MCP config live under, distinct from the project-only `.crush/` directory name. Unless `$CRUSH_SKILLS_DIR` is set, Crush also scans several shared directories it does not own (globally `~/.config/agents/skills/`, `~/.agents/skills/`, `~/.claude/skills/`; per-project `.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, also checked at a git worktree's common root) — rulesync writes only the Crush-specific path above, leaving those shared roots to their own targets. Besides `name`/`description`, the `crush:` block round-trips `user-invocable`, `disable-model-invocation`, and the shared `license`/`compatibility`/`metadata` packaging trio, all falling back to their root-level defaults when the section omits them; since Crush's `UserInvocable` field is a non-pointer Go `bool`, an omitted `user-invocable` resolves to `false`, keeping the skill reachable by the model but hidden from Crush's command palette. `compatibility` and `metadata` are normalized to the bare string / `map[string]string` shapes Crush's parser requires before being written, even if the rulesync root default holds the legacy object form. See [Crush's skill loader](https://github.com/charmbracelet/crush/blob/main/internal/skills/skills.go).\n\n> **Cline skills note:** Cline stores a skill's enabled state in the skill file itself: switching a skill off in the Cline UI (CLI config panel or the desktop app's Customize view) writes `disabled: true` into its `SKILL.md`, and Cline also treats `enabled: false` as disabled. Every key in the `cline:` section of `.rulesync/skills/*/SKILL.md` is written into the generated `.cline/skills/<name>/SKILL.md` (project) / `~/.cline/skills/<name>/SKILL.md` (global, via `--global`) — canonical `name` and `description` always win over same-named keys there — and `rulesync import --targets cline` lifts every key beyond `name`/`description` back into `cline:`. So a skill switched off in Cline stays off across `rulesync import` and `rulesync generate`; without that import, the next `generate` rewrites the file without the flag and re-enables the skill. `disabled` is not the canonical `disable-model-invocation`, which hides a skill from the model only, so the two are not mapped onto each other. See [`skill-frontmatter-toggle.ts`](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/config/skill-frontmatter-toggle.ts).\n\n## `.rulesync/mcp.jsonc`\n\n`.rulesync/mcp.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/mcp.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nA key named `__proto__`, `constructor`, or `prototype` is rejected with an error naming its path, rather than being dropped in silence. The \"Rejected keys\" paragraph in the `.rulesync/permissions.jsonc` section below describes this handling, which is the same for all three source files.\n\nExample:\n\n```json\n{\n \"mcpServers\": {\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json\",\n \"serena\": {\n \"description\": \"Code analysis and semantic search MCP server\",\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\n \"--from\",\n \"git+https://github.com/oraios/serena\",\n \"serena\",\n \"start-mcp-server\",\n \"--context\",\n \"ide-assistant\",\n \"--enable-web-dashboard\",\n \"false\",\n \"--project\",\n \".\"\n ],\n \"env\": {}\n },\n \"context7\": {\n \"description\": \"Library documentation search server\",\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@upstash/context7-mcp\"],\n \"env\": {}\n }\n }\n}\n```\n\n### Tool-scoped server blocks (`{toolname}.mcpServers`)\n\nServers under the shared `mcpServers` key are emitted to every targeted tool. To scope a server to a single tool, add a tool-scoped `{toolname}` block alongside it — mirroring `{toolname}.hooks` in `.rulesync/hooks.jsonc` and `{toolname}.permission` in `.rulesync/permissions.jsonc`:\n\n```jsonc\n{\n \"mcpServers\": {\n \"shared-server\": { \"type\": \"stdio\", \"command\": \"echo\" },\n },\n \"claudecode\": {\n \"mcpServers\": {\n // Added only to Claude Code's MCP config.\n \"claude-only-server\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" },\n // `null` removes a shared server for Claude Code only.\n \"shared-server\": null,\n },\n },\n}\n```\n\n- A tool-scoped entry with the same name as a shared server **replaces it wholesale** for that tool (no field-level merge).\n- A tool-scoped entry set to `null` **removes** the shared server for that tool.\n- Any MCP-capable `--targets` name is accepted as a block key (`claudecode`, `cursor`, `codexcli`, ...). Targets that share one output file resolve identically so the shared file never depends on generation order: the deprecated `claudecode-legacy` target reads the `claudecode` block; the `kiro-cli` / `kiro-ide` targets read the `kiro` block (all three write the same `.kiro/settings/mcp.json`); the `antigravity-ide` / `antigravity-cli` targets both apply both `antigravity-*` blocks in a fixed order (`antigravity-ide` first, then `antigravity-cli` — the CLI block wins per server) because they share their output file at both scopes (`.agents/mcp_config.json` in project mode, `~/.gemini/config/mcp_config.json` in global mode); and in project mode the `claudecode` (or `claudecode-legacy`), `codebuddy`, `commandcode` and `qoder` targets all apply the `claudecode`, `codebuddy`, `commandcode` and `qoder` blocks in that order (a later block wins per server) because they share the project-root `.mcp.json` — in global mode their files differ (`~/.claude.json`, `~/.codebuddy/.mcp.json`, `~/.commandcode/mcp.json` and `~/.qoder/settings.json`), so there each reads only its own block.\n\n> **Generation filter: per-server `enabled`.** Set `\"enabled\": false` on a server (in the shared map or a tool-scoped block) to keep the definition in the source file while emitting it to **no** tool config at all — a temporary off switch that does not lose the entry. Omitted means enabled, so existing configs keep generating everything; writing `\"enabled\": true` is opt-in clarity. This is distinct from the canonical `disabled`, which is a **pass-through** field the tools read (written as `disabled: true`, or translated to each tool's own spelling): `enabled: false` wins and drops the server entirely, while `disabled` only matters for servers still emitted. The field is rulesync-source-only and never reaches generated output — several tools (OpenCode, Kilo, Grok CLI, Goose) have a native `enabled` field with different semantics — and import never invents it: a tool's native enabled/disabled state keeps mapping to the canonical `disabled` (though a stray hand-written `enabled` in a passthrough-imported tool file does come back as the canonical filter). Two edges to know: a tool-scoped entry **replaces the shared entry wholesale**, so a same-named tool-scoped entry without `enabled: false` re-emits the server for that tool (per-tool re-enabling); and on merge-style shared configs (e.g. Hermes Agent's `config.yaml`), disabling a previously generated server stops writing it but does not remove the already-written entry — same as deleting the definition.\n\n> **Deprecated: per-server `targets`.** The older per-server `\"targets\": [\"tool\", ...]` array is still honored as a filter (a missing value or `[\"*\"]` means every tool), but it is deprecated and logs a warning at generate time. Migrate by moving the server into the matching `{toolname}.mcpServers` block(s).\n\n> **JetBrains AI Assistant note:** Rulesync writes the native `{ \"mcpServers\": { ... } }` configuration to `.ai/mcp/mcp.json` in project mode and `~/.ai/mcp/mcp.json` in global mode. Both scopes support STDIO and remote server entries using the shape documented in [JetBrains AI Assistant's MCP guide](https://www.jetbrains.com/help/ai-assistant/mcp.html).\n\n> **Antigravity note:** The Antigravity IDE and CLI share `.agents/mcp_config.json` (project) and `~/.gemini/config/mcp_config.json` (global). Antigravity CLI 1.1.24+ accepts `//` and `/* */` comments and trailing commas in `mcp_config.json` (see the [Antigravity CLI changelog](https://github.com/google-antigravity/antigravity-cli/blob/main/CHANGELOG.md)), so Rulesync reads the existing file as JSONC; the rewrite is plain JSON, so comments are dropped but the run no longer fails. HTTP/SSE servers use `serverUrl` in place of the canonical `url`, translated in both directions.\n\n#### JSON Schema Support\n\nRulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/mcp.jsonc`:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json\",\n \"mcpServers\": {}\n}\n```\n\n### Transport types (`type` / `transport`)\n\nThe `type` (and the equivalent `transport`) field accepts `local`, `stdio`, `sse`, `http`, `ws`, and `streamable-http`. `streamable-http` is the MCP specification's name for the HTTP transport and is accepted as an alias of `http`, so configurations copied from a server's documentation work unchanged. `ws` is the WebSocket transport (a persistent bidirectional connection) and accepts the same `url`/`headers`/`headersHelper`/`timeout` fields as `http`. Tools that do not recognize a given transport keep it on round-trip but may ignore it at runtime.\n\n> **OpenCode skills note:** on import, Rulesync also reads the `skills.paths` array of `opencode.json` / `opencode.jsonc` (\"Additional paths to skill folders\") and scans each entry as an extra skill root, so skills a project keeps outside `.opencode/skills/` are no longer invisible to `rulesync import`. These roots are import-only — generation keeps writing to Rulesync's own managed root — and a skill of the same name found in a managed root still wins. Each entry is resolved against the directory the config was read from — the project root in project mode, `~/.config/opencode/` in global mode — which is what OpenCode itself does. An absolute path is ignored, and so is one that escapes that directory — judged by where the entry resolves rather than by how it is spelled, so a relative entry that is a symbolic link pointing outside the directory is ignored too — and a directory under a configured root that is not a skill is skipped with a warning rather than failing the run, since a configured root is arbitrary user territory. `skills.urls` is a remote-fetch surface and is out of scope for a file-based generator. The OpenCode V2 flat `skills` array, which OpenCode V1 also accepts (from v1.18.24), is read the same way: its entries that are not `http(s)://` URLs are taken as `skills.paths`.\n\n> **MiMo Code skills note:** MiMo Code (an OpenCode distribution) reads skills from `.mimocode/skills/<name>/SKILL.md` (project) / `~/.config/mimocode/skills/<name>/SKILL.md` (global, via `--global`), also accepting the singular `skill/` directory on import. Tool-specific frontmatter goes under a `mimocode` key, which takes the same fields as the `opencode` key so a skill round-trips (MiMo Code itself ignores them). On import, the `skills.paths` array of `.mimocode/mimocode.json(c)` is read too, with relative entries resolved against the project root as MiMo Code does; a global config's relative entries depend on the project and are not read.\n\n> **oh-my-pi skills note:** oh-my-pi (`omp`) reads skills from `.omp/skills/<name>/SKILL.md` (project) / `~/.omp/agent/skills/<name>/SKILL.md` (global, via `--global`) in Pi's format. Tool-specific frontmatter goes under an `omp` key, which takes the same fields as the `pi` key and is handled the same way (see the Pi skills note).\n\n> **GitLab Duo CLI skills note:** GitLab Duo reads [Agent Skills](https://docs.gitlab.com/user/duo_agent_platform/customize/agent_skills/) from the repository-root `skills/<name>/SKILL.md` (project) and `~/.gitlab/duo/skills/<name>/SKILL.md` (global, via `--global`). The project `skills/` directory is read by GitLab Duo from the committed repository, so `rulesync gitignore` does not ignore it. Every skill targeting `gitlabduo` — including a `targets: [\"*\"]` one — is written there, so if your repository already uses a top-level `skills/` directory for something else (a skill collection, skills distributed to other tools), limit such skills' `targets` or leave `gitlabduo` out of the skills feature. User-level skills are an experiment and are loaded only when the CLI is started with `--enable-global-skills true` or `GITLAB_ENABLE_GLOBAL_SKILLS=true`. Rulesync always uses the default `~/.gitlab/duo/` location and does not follow `GLAB_CONFIG_DIR` or `XDG_CONFIG_HOME`. Because the project root `skills/` directory may hold unrelated content, only a subdirectory that contains a `SKILL.md` is imported. For the same reason `generate --delete` never removes a skill directory from the project `skills/` directory: hand-authored skills there are indistinguishable from ones Rulesync generated earlier, so a generated skill whose `.rulesync/skills/` source you removed has to be deleted by hand. (Stale files inside a skill directory that the run does generate are still cleaned up, as for every other target — so a hand-authored `skills/<name>/` sharing its name with a Rulesync skill is overwritten.) The global `~/.gitlab/duo/skills/` directory is swept as usual.\n\n> **OpenCode MCP note:** besides the mapped transport fields, Rulesync passes the OpenCode per-server keys `timeout`, `oauth`, `protocol` (`legacy` | `auto` | `2026-07-28`, OpenCode V2) and `codemode` (OpenCode V2) through verbatim in both directions. Other tools' MCP keys are never copied into `opencode.json`. Rulesync reads and writes OpenCode's V1 file shape — a flat `mcp` map keyed by server name — so `protocol` and `codemode` reach the adapter only when a canonical server carries them: authored by hand in `.rulesync/mcp.jsonc`, or imported from a flat-`mcp` `opencode.json` entry that already has them. They are written only in that case and never added on their own, so a V1-only install is unaffected unless you author them (OpenCode V1's published schema does not list them). On import, the OpenCode V2 spellings are read the way OpenCode V1 itself lowers them (since v1.18.24): servers under the `mcp.servers` envelope are merged into the flat map (a flat entry of the same name wins — warned about when the two differ — and a server literally named `servers` or `timeout` is still read as a server); on a `local` or `remote` server, `disabled` becomes `enabled` (an explicit `enabled` wins, with a warning when the two contradict each other, and a non-boolean `enabled` or `disabled` is rejected rather than guessed at), snake_case `oauth` keys (`client_id`, `client_secret`, `callback_port`, `redirect_uri`) become camelCase while `auth_server_metadata_url`, which V1 has no spelling for, is dropped with a warning, and a per-server `timeout` object becomes the number `timeout` only when `catalog` equals `execution`, both are positive integers, and there is no `startup` — otherwise it has no V1 equivalent and is dropped with a warning. The global `mcp.timeout` object (whose values must be positive integers; otherwise it is read as a server named `timeout`) is not imported (a non-empty one is warned about), since its V1 equivalent `experimental.mcp_timeout` has no canonical counterpart. Generate still rewrites the whole `mcp` key in the flat V1 shape, which OpenCode V2 also accepts, so the envelope and a global `mcp.timeout` are not kept there, and generate warns when the existing file has a non-empty one of either. MiMo Code does not lower V2 spellings, so this applies to OpenCode only. See the [OpenCode MCP docs](https://opencode.ai/v2/docs/mcp-servers/).\n\n> **Kilo Code note:** Kilo's MCP config uses its own native shape in `kilo.jsonc` (`type: \"local\" | \"remote\"`, `environment`, `enabled`, `command` as an array). Rulesync maps `stdio`/`local` ⇄ Kilo `local` and `http`/`sse` ⇄ Kilo `remote`; on import, Kilo `remote` is normalized to the canonical `http` transport (the deprecated `sse` is no longer emitted). The Kilo-specific `timeout` (local + remote, a positive integer in milliseconds) and `oauth` (remote only — either an OAuth-config object or `false` to disable auto-detection) fields are preserved on round-trip. The `kilo.jsonc` `skills` config key (`skills.paths` for extra skill locations and `skills.urls` for remote skill manifests) is likewise preserved when Rulesync writes the file. A bare `{\"enabled\": false}` entry — Kilo's way of switching off a server another config layer defines, such as the global config or a marketplace — round-trips as itself: it imports as a canonical server carrying only `disabled: true`/`disabled: false` and no transport, and a server in that shape is written back as `{\"enabled\": …}` rather than as a local server with an empty command it cannot start. The enabled state has to be stated outright in both directions: for a transport-less server that says nothing about `disabled`, a toggle already in `kilo.jsonc` is left exactly as it is, and if there is none the server is skipped with a warning — a toggle overrides the layer that defines the server, so writing `enabled: true` for it would switch back on what you turned off there. Kilo's per-tool `enabledTools`/`disabledTools` reach the generated file at all now — they used to be stripped before this adapter saw them, so a filter read out of `kilo.jsonc` was deleted from it on the next generate. The filters are written as Kilo [MCP tool permission keys](https://kilo.ai/docs/automate/mcp/using-in-kilo-code#auto-approve-tools) in the `permission` block — `enabledTools` as `\"{server}_{tool}\": \"allow\"`, `disabledTools` as `\"{server}_{tool}\": \"deny\"`, with both names sanitized the way Kilo names MCP tools (every character outside `a-z A-Z 0-9 _ -` becomes `_`, except that a `*` or `?` in a tool name is kept, so `disabledTools: [\"*\"]` becomes the wildcard key `\"{server}_*\"`) — rather than the legacy top-level `tools` map, which Kilo only folds into `permission` through a compatibility shim. The `permission` block is shared with the permissions feature: for every server the canonical config or the file's `mcp` block names, the MCP feature owns the plain `\"allow\"`/`\"deny\"` keys of that server's tools (including wildcard ones such as `\"{server}_*\"`) and rebuilds them on each generate (removing a `deny`, or a legacy `tools` entry set to `false`, that no `disabledTools` lists anymore warns), while other wildcard keys (such as the catch-all `\"*\"`), `\"ask\"`, the `{\"*\": …}` maps Kilo's \"Approve Always\" saves, Kilo's built-in permissions (such as `external_directory` for a server named `external`, or `repo_clone` for one named `repo`), and every other key are left alone; the same servers' entries are removed from a legacy `tools` map. Keys are matched to servers by name prefix, with the longest matching server name winning, so a key of a server defined only in another Kilo config layer whose name extends a listed one (`github_enterprise_…` next to a listed `github`) is treated as the listed server's. Note that in Kilo `\"allow\"` means the tool runs without asking, so an `enabledTools` entry is auto-approved even when a catch-all `\"*\": \"ask\"` comes before it. When two servers sanitize to the same key, a `deny` wins over an `allow`. Because Kilo evaluates `permission` last-match-wins in key order, the permissions feature moves exact `\"deny\"` keys to the end of the block, so a broad `allow` it appends can never lift an MCP `disabledTools` deny. A wildcard `disabledTools` entry (`\"{server}_*\": \"deny\"`) is not moved, because moving a wildcard deny would also override the narrower allows written after it on purpose; a catch-all `\"*\": \"allow\"` placed after it still wins, as it already did over the legacy `tools` map. A skipped server's filters are written either way, since a permission key is keyed by server name and reaches servers `mcp` does not list. On import, `permission` keys (a plain `\"allow\"`/`\"deny\"` or a `{\"*\": …}` map) of a listed server are read back as filters, and so is the legacy `tools` map (`permission` wins a key both state, as in Kilo's own shim); a legacy `tools` entry naming no listed server comes back as a server carrying nothing but the filters. A `permission` key naming no listed server is not imported as an MCP filter — it cannot be told apart from a built-in permission — and is imported by the permissions feature into its `kilo` override instead. A server with no transport — a toggle, or one of those filter-only entries — is imported into the tool-scoped `kilo.mcpServers` block rather than the shared `mcpServers` map, because an entry with no command and no url is a server the other tools' configs cannot start. All of this applies equally to OpenCode: its published schema carries the same bare-toggle union member, it round-trips a toggle as itself under the same explicit-state rule, its filters still use the top-level `tools` map (keyed by server name in the same way), and its transport-less servers land in `opencode.mcpServers`. The entry must carry no field of a local or remote server (`type`, `command`, `url`, `headers`, `environment`, `cwd`, `timeout`, `oauth`); an entry that is malformed in some other way still fails loudly rather than being quietly read as a toggle and written back with its command, headers, or OAuth secrets gone, while an unrelated key Kilo adds later is accepted rather than failing the run (it is not carried across the round-trip, though — a toggle imports as its enabled state and nothing else). Since a toggle keeps nothing but its enabled state, a canonical server that declares no transport but still carries fields such as `args` or `env` is written as a toggle with those fields dropped and a warning naming them. A server that names a transport it cannot reach — a `type` with no `command`, an `http` with no `url` — is skipped with a warning instead, because `{\"type\": \"local\", \"command\": []}` is a server Kilo cannot start. An existing `kilo.jsonc` carrying that shape (earlier Rulesync versions wrote it) imports as a server with no transport rather than failing the run. The same applies to OpenCode, whose config uses the same shape. Rejecting it used to fail the whole `--targets kilo` run rather than the MCP feature alone, because `kilo.jsonc` is the file the rules feature writes too. Environment variable references: Kilo expands only its own `{env:VAR}` syntax, and only in a trusted config. In global mode (`~/.config/kilo/kilo.jsonc`) the canonical `${VAR}` in `env` and `headers` values is written as `{env:VAR}`. A project `kilo.jsonc` is untrusted — any `{env:` in it makes Kilo drop the whole file (or, inside MCP `headers`, the server) — so in project mode the references are written literally as `${VAR}`, which Kilo does not expand, and a warning names the servers that carry them; a server that already carries `{env:` anywhere (for example Cursor's `${env:VAR}`) is skipped with a warning in project mode so it cannot cost the rest of the shared `kilo.jsonc`. On import, `{env:VAR}` maps back to `${VAR}` (Cursor's `${env:VAR}` is left untouched).\n\n> **MiMo Code note:** MiMo Code (an OpenCode distribution) uses OpenCode's MCP shape, written to the `mcp` (and `tools`) keys of `.mimocode/mimocode.jsonc` (project; an existing `.mimocode/mimocode.json` is updated instead) or `~/.config/mimocode/mimocode.jsonc` (global). Everything described for OpenCode applies, and a tool-scoped block goes under `mimocode.mcpServers`.\n\n> **oh-my-pi note:** oh-my-pi (`omp`) reads the `mcpServers` map of `.omp/mcp.json` (project) / `~/.omp/agent/mcp.json` (global). Rulesync writes each server with an explicit `type` (`stdio`, `http` — also for `streamable-http` — or `sse`), a string `command` plus `args` for stdio servers, and `enabled: false` for a `disabled` server; other top-level keys of an existing file (`$schema`, `disabledServers`, …) are preserved, and the global file is never deleted. WebSocket servers are skipped with a warning. oh-my-pi expands `${VAR}` and `${VAR:-default}` itself, so values are written unchanged. `enabledTools` / `disabledTools` have no oh-my-pi counterpart and are not written. A tool-scoped block goes under `omp.mcpServers`.\n\n> **Pi note:** Pi (1.0+) has built-in MCP support and reads the `mcpServers` map of `.pi/mcp.json` (project — read only after project trust is granted) / `~/.pi/agent/mcp.json` (global). Rulesync writes each server with an explicit `type` (`stdio`, or `http` — also for `streamable-http`), a string `command` plus `args` for stdio servers, `enabled: false` for a `disabled` server, and `timeout` converted from Rulesync's milliseconds to Pi's seconds (a non-positive timeout is dropped with a warning, since Pi rejects the server for it); import reverses these mappings. Pi rejects the legacy SSE transport, so `sse` servers — like WebSocket servers and any remote server whose `url` is not `http(s)://` — are skipped with a warning. Pi-specific keys such as `exposure`, `toolExposure`, `description`, `oauth` and `auth` pass through; put them under a `pi.mcpServers` block to keep them out of other tools' configs. Pi accepts `auth` only in the global file, so it is dropped with a warning from the project file. Pi rejects server names other than letters, digits, `_` and `-`, and treats names that differ only in `-` and `_` as the same server, so such a name (or the second of two such names) is skipped with a warning. Other top-level keys of an existing file (`autoEnableCodemode`, …) are preserved, and the global file, which `pi mcp add` and `/mcp` also edit, is never deleted. Pi expands `${VAR}` and `$VAR` in `env` and `headers` values, so they are written unchanged (note that Pi runs a value starting with `!` there as a shell command); it does not expand `${VAR:-default}`, and in `command`, `args` and `cwd` it expands only a leading `~/`, so a server carrying such a reference gets a warning (write `~/...` rather than `${HOME}/...` there). `enabledTools` / `disabledTools` have no Pi counterpart and are not written. A project entry without `command`, `url` or `type`, which Pi treats as an override of a same-named user-level server, is not modeled: Rulesync skips a server without a transport. See the [Pi MCP docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md).\n\n> **GitLab Duo CLI note:** GitLab Duo reads [MCP servers](https://docs.gitlab.com/user/gitlab_duo/model_context_protocol/mcp_clients/) from `.gitlab/duo/mcp.json` (project) and `~/.gitlab/duo/mcp.json` (global) in the `{ \"mcpServers\": { ... } }` shape. Every server is written with an explicit `type` (`stdio`, `http` or `sse`), inferred from `command` / `url` when the source leaves it out; `streamable-http` becomes `http`, and servers with another transport (such as `ws`) are skipped with a warning. The GitLab-specific `approvedTools` (`true` or a list of tool names that run without a prompt) is passed through — put it under a `gitlabduo.mcpServers` block to keep it out of other tools' configs.\n\n> **Zed note:** Zed configures MCP servers under `context_servers` in its shared settings file (`.zed/settings.json` project, `~/.config/zed/settings.json` global — `%APPDATA%\\Zed\\settings.json` on Windows), whose value is an untagged shape with no `type` field: a stdio server is `{\"command\": <string>, \"args\", \"env\", \"timeout\"}`, a remote one `{\"url\", \"headers\", \"timeout\"}`, and an extension-provided one neither. Rulesync translates the canonical fields into those shapes instead of forwarding them verbatim (which used to hand Zed keys it silently ignores — most seriously `disabled: true`, which left the server **enabled**): `disabled: true` becomes `enabled: false` (and imports back as `disabled: true`), the `httpUrl` alias is normalized to `url`, an array `command` is flattened to Zed's single command string with the rest prepended to `args`, and canonical-only fields (`type`/`transport`, `alwaysAllow`, `trust`, `cwd`, `networkTimeout`, the Kiro lists) are dropped. Fields rulesync does not model — a remote server's `oauth` block, an extension server's `settings` — pass through untouched, so they are best authored in the tool-scoped `zed.mcpServers` block. A server Zed cannot start is skipped with a warning rather than written broken: an `sse` or `ws` server (Zed has neither transport), a remote server with no `url`, a local one with no `command`. A server with no transport at all is written as Zed's extension-provided variant, and on import such an entry lands in the tool-scoped `zed.mcpServers` block rather than the shared `mcpServers` map, since other tools cannot start it.\n\n> **Kimi Code note:** MCP servers are written to `.kimi-code/mcp.json` (project) and `~/.kimi-code/mcp.json` (global). Kimi Code supports stdio, HTTP, and SSE plus `env`, `cwd`, `headers`, `bearerTokenEnvVar`, `enabled`, `deferred` (Kimi Code 0.43.0: load the server's tools on demand instead of inline), `executor` and `runtime_id` (where a stdio server runs: `local` or `kaos`, and the runtime to use; accepted by Kimi's server schema though not yet documented), `startupTimeoutMs`, `toolTimeoutMs`, `enabledTools`, and `disabledTools`; Rulesync preserves the canonical fields that Kimi accepts. Canonical `local` maps to stdio and `streamable-http` maps to HTTP. WebSocket servers are skipped with a warning because Kimi has no WebSocket transport. A `kimi-code` block may also carry `startupTimeoutMs` / `toolTimeoutMs`, which are **not** per-server: they become Kimi's `[mcp] startup_timeout_ms` / `tool_timeout_ms` defaults in the shared global `~/.kimi-code/config.toml`, applying to every MCP server including ones Rulesync did not write (a per-server value in `mcp.json` still wins). Global scope only, since `config.toml` has no project counterpart, and merged in place so the `hooks` and `permission` sections of the same file survive. The merge is per key: authoring only one of the two timeouts leaves a hand-written sibling alone, and dropping the override entirely leaves the section as it stands rather than deleting it — remove the keys from `config.toml` by hand if you want them gone. See the [Kimi Code MCP docs](https://moonshotai.github.io/kimi-code/en/customization/mcp.html) and [config-files reference](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#mcp).\n\n> **Hermes Agent note:** Hermes MCP servers live under `mcp_servers` in the shared `~/.hermes/config.yaml`. Rulesync preserves the `oauth` keys Hermes reads (`redirect_uri`, `redirect_host`, `redirect_port`, `client_id`, `client_secret`, `client_name`, `client_metadata_url`, `cimd`, `token_endpoint_auth_method`, `application_type`, `user_agent`, `flow`, `timeout`, and `scope`) — `scope` is the space-separated string Hermes sends on registration and device login; a `scopes` list, which earlier Rulesync versions wrote but Hermes never read, is folded into `scope` in both directions — plus `protocol` (`auto` / `stateless` / `legacy`, v0.21), `lazy` (defer the connection to first tool use, v0.21), `idle_timeout_seconds`, `max_lifetime_seconds` (also under a `lifecycle:` mapping, carried as an opaque object), `ssl_verify` (`true`/`false` or a PEM CA-bundle path), `skip_preflight`, `keepalive_interval` (liveness ping cadence in seconds), `trust` (`full` or `untrusted`, where every write-capable tool call needs approval — copied verbatim, since Hermes reads any unrecognized value as `untrusted`), and the `sampling`, `elicitation`, and `identity_header` mappings (carried as opaque objects so new sub-keys keep working). A canonical `sse` server is written with Hermes's own `transport: sse` (v0.20.0) and imports back as `type: \"sse\"`; without it Hermes connects to a `url` server over Streamable HTTP, so the transport would silently change. Streamable HTTP is Hermes's default and stays implicit. On import, portable server fields remain in shared `mcpServers`; Hermes-only fields are isolated in the full `hermesagent.mcpServers.<name>` replacement block so they cannot leak to other targets.\n\n> **Devin note:** Since Devin v3000.3 (the Local 3.6 release), MCP servers live in a dedicated `mcpServers`-keyed file: `.devin/mcp_config.json` (project) and `~/.config/devin/mcp_config.json` (global, via `--global`). The file is MCP-only and rulesync-owned (rewritten whole, deletable), unlike the shared `.devin/config.json` that permissions and hooks keep patching in place. Rulesync no longer writes the legacy `config.json` `mcpServers` key — Devin auto-migrates it away on startup, so re-seeding it would fight the migration — but import still falls back to that key when no `mcp_config.json` exists, so pre-v3000.3 repos migrate cleanly. The gitignored personal override `.devin/mcp_config.local.json` is never read or written (it is covered by the derived `.gitignore`). See the [Devin MCP configuration docs](https://docs.devin.ai/cli/extensibility/mcp/configuration). The `devin-plugin` target instead writes a plugin bundle's `.mcp.json` under `mcpServers` (project scope only). See the [plugin packaging guide](../guide/plugin-packaging.md#devin-plugins).\n\n> **Warp note:** Warp reads file-based MCP servers from `.warp/.mcp.json` (project) and `~/.warp/.mcp.json` (global). Warp spells the working directory `working_directory` (used for resolving relative paths), so the canonical `cwd` is translated to it on generate and back on import; a tool-native `working_directory` already on the server wins over `cwd`. See the [Warp MCP docs](https://docs.warp.dev/agent-platform/capabilities/mcp/).\n\n> **Warp Agent CLI note:** The standalone Warp Agent CLI (the `warp` binary, target `warpcli`) keeps its own config root, separate from the Warp app's: `~/.warp_cli/` on macOS, `~/.config/warp-terminal/cli/` on Linux, and `%LOCALAPPDATA%\\warp\\Warp\\config\\cli\\` on Windows. It reads MCP servers from `.mcp.json` in that directory only — \"Project-scoped MCP config files in repositories are not detected\" — so `warpcli` is **global-only** (`--global`). The file format is the Warp app's (including the `cwd` ⇄ `working_directory` translation), and a `warpcli` tool-scoped block in `.rulesync/mcp.jsonc` applies to it alone. Rules and skills are shared with the app (`AGENTS.md`, `.agents/skills/`), so keep generating them with the `warp` target. See the [Warp Agent CLI configuration docs](https://docs.warp.dev/agents/cli/configuration/).\n\n> **Pool note:** Pool reads MCP servers from the `mcp_servers` block of its YAML settings file: `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) and `~/.config/poolside/settings.yaml` (global, via `--global`). The merge is in place — every other top-level key (`pool`, `tools`, `sandbox`, `hooks`, …) is preserved and the file is never deleted. Stdio servers are written as `command`/`args`/`cwd`/`env`; remote servers as a `transport` block (`type: http` or `type: sse`, `url`, and `headers` as Pool's list of `\"Name: value\"` strings, which imports back to the canonical map). `streamable-http` is written as `http`; `ws` servers are skipped with a warning because Pool offers no WebSocket transport. Tool filters map onto Pool's own switches: `enabledTools` becomes `enabled_tools` (the tools Pool exposes at all) and `disabledTools` becomes `deny` (patterns agents may never use); Pool's approval allowlist is authored on the canonical server as the namespaced `poolAllow` (written as `allow`, imported back as `poolAllow`, and stripped from every other tool's output). Note that Pool's `deny`/`enabled_tools` accept glob patterns while the canonical lists are literal tool names, so a pattern imported from Pool is carried to other tools' `disabledTools`/`enabledTools` as a literal name — the import warns when it sees one (`allow` is exempt, since `poolAllow` never reaches another tool). Because `.poolside/settings.yaml` is committed, prefer `env` entries that reference environment variables (or Pool's own `secrets` key) over literal credentials in `headers`. See the [Pool MCP docs](https://docs.poolside.ai/mcp-servers) and the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **DeepSeek Harness note:** DeepSeek Harness (`dsh`) has no MCP-only file: an MCP server is an `insert` row for the bundled `@deepseek-ai/dsh-mcp-client` plugin in a Cordis patch layer. Rulesync writes those rows into the user-owned home layer `~/.dsh/cordis.patch.yml` (the `$DSH_HOME` default), so `dsh` is **global-only** (`--global`); the profile layer is written by the app itself and there is no project-scoped MCP file. The file is a YAML list of patch entries, and the merge is in place: rulesync owns every `@deepseek-ai/dsh-mcp-client` row. A server that already has a row is rewritten where that row stood, in the same entry (so a row inserted into a group stays in that group), reusing its `id` so id-targeted patches keep applying; rows of servers no longer defined are removed; new servers are appended to the first plain top-level `insert` entry (or a new one), with the id `mcp-<name>` (suffixed if that id is already taken). Every other patch entry and insert row — including their `!!js` expressions — is preserved, and the file is never deleted. A file whose root is not a list is refused rather than overwritten. YAML comments are not preserved on rewrite and anchors/aliases are expanded, and a `!!js` expression inside an MCP row rulesync rewrites is replaced by the canonical value. On import, a `!!js` value in `env` or `headers` is dropped, and a row whose `serverName`, `transport`, `command`, `url`, `cwd`, `args`, `disabled` or one of the dsh-only fields below is (or, for `reconnect`, contains) a `!!js` expression is skipped with a warning — define that server in `.rulesync/mcp.jsonc` yourself, since the next generate removes the row otherwise. Stdio servers become `transport: stdio` with `command`/`args`/`env`/`cwd`; `http`/`streamable-http` servers become `transport: streamable-http` with `url`/`headers`; `sse` and `ws` servers are skipped with a warning because the client only speaks stdio and Streamable HTTP, and so is a server whose name does not match the harness's `[A-Za-z0-9_-]{1,32}` pattern. Canonical `disabled: true` becomes the entry-level `disabled` switch. The client-only `toolCallTimeoutMs`, `failOnStartupError`, `maxInstructionBytes` and `reconnect` fields are carried when authored on a server; import puts them in a `dsh.mcpServers` replacement block so they never leak to other tools; canonical `timeout` is not mapped. `enabledTools`/`disabledTools` are not supported. See the [dsh-mcp-client README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md).\n\n> **Takt note (partial / transport-allowlist only):** Takt does **not** have a project- or global-level registry of MCP server _definitions_. The concrete `mcp_servers` map (`command`/`args`/`env` or `type`/`url`/`headers`) is declared **per workflow step** inside individual workflow YAML files; there is no top-level `mcp_servers` key in `config.yaml`, and Takt's config loader hard-rejects unknown top-level keys (introduced with MCP support in [Takt v0.21.0](https://github.com/nrslib/takt/blob/main/CHANGELOG.md)). What `config.yaml` _does_ hold is the **default-deny transport allowlist** `workflow_mcp_servers: { stdio, sse, http }` — without it, workflow-defined MCP servers are refused regardless of how they are declared. So Rulesync emits **only** this allowlist into the shared `.takt/config.yaml` (project) / `~/.takt/config.yaml` (global), enabling exactly the transports your `.rulesync/mcp.jsonc` servers use (`local`/`stdio` ⇒ `stdio`; `sse` ⇒ `sse`; `http`/`streamable-http`/`ws` ⇒ `http`). The merge is in place — every other top-level key (`provider`, `provider_profiles`, …) is preserved and the file is never deleted. **Documented lossiness:** per-server names, commands, env, URLs, and headers are not representable in `config.yaml` and are intentionally **not** written; you still declare the concrete servers in your workflow YAML steps, and Rulesync only opens the transport gate that permits them. As a corollary, **import** cannot reconstruct server definitions from a transport allowlist and yields an empty `mcpServers` map. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).\n\n### MCP Tool Config (`enabledTools` / `disabledTools`)\n\nYou can control which individual tools from an MCP server are enabled or disabled using `enabledTools` and `disabledTools` arrays per server.\n\n```json\n{\n \"mcpServers\": {\n \"serena\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"--from\", \"git+https://github.com/oraios/serena\", \"serena\", \"start-mcp-server\"],\n \"enabledTools\": [\"search_symbols\", \"find_references\"],\n \"disabledTools\": [\"rename_symbol\"]\n }\n }\n}\n```\n\n- `enabledTools`: An array of tool names that should be explicitly enabled for this server.\n- `disabledTools`: An array of tool names that should be explicitly disabled for this server.\n\n> **Kiro note:** Kiro MCP servers are written under `mcpServers` in `.kiro/settings/mcp.json` (project) and `~/.kiro/settings/mcp.json` (global). Kiro supports `disabledTools` natively and Rulesync preserves it on generate and import. Kiro does not expose a corresponding per-server `enabledTools` allowlist, so that field is omitted for Kiro targets. The two Rulesync-only authoring keys are translated onto the field names Kiro actually reads: `kiroAutoApprove` becomes `autoApprove` (tools run without a confirmation prompt) and `kiroAutoBlock` becomes `disabledTools` (tools hidden from the agent). A server that already spells `autoApprove` or `disabledTools` natively keeps working — the two lists are merged rather than one overwriting the other. On import, `autoApprove` is lifted back into `kiroAutoApprove`; `disabledTools` stays as-is because it is already a canonical Rulesync field with the same meaning, so `kiroAutoBlock` has no import counterpart. That makes `kiroAutoBlock` a redundant spelling of `disabledTools`: a Kiro config imported after a generate comes back as canonical `disabledTools`, which then also reaches the other targets that support it. Prefer authoring `disabledTools` directly, which makes that scope explicit from the start.\n\n> **Roo Code / Zoo Code note:** Both targets write `.roo/mcp.json` through the same adapter, and their per-server MCP schema is **denylist-only**: `disabledTools` clears each named tool's `enabledForPrompt` (the tool stops being offered to the model), and there is no corresponding `enabledTools` allowlist, so that field is omitted for these targets. The server config is emitted verbatim, so `disabledTools` round-trips as itself. Earlier Rulesync versions stripped it before the adapter saw it, which meant a filter you had written into `.roo/mcp.json` by hand — or through Zoo Code's own tool toggles, which write the same key — was deleted on the next generate.\n\n> **deepagents note:** MCP servers are written to `.deepagents/.mcp.json` (project) / `~/.deepagents/.mcp.json` (global). Several translations apply, because dcode **drops an individual server it cannot validate** — the rest of the file still loads, so a mistake here is silent rather than loud. **Transports:** dcode accepts only `stdio`, `sse` and `http` (plus the aliases `streamable_http` / `streamable-http` → `http`), so canonical `local` is written as `stdio` and `streamable-http` as `http`; canonical `ws` has no counterpart and the server is skipped with a warning at generate time, where you can still see it. Whether the value lands under `type` or `transport` follows whichever key you authored — dcode reads the two interchangeably, and with neither set it infers `http` from a `url` and `stdio` otherwise. On import, both spellings of the `streamable_http` alias come back as canonical `http`. **Tool filters:** canonical `enabledTools` becomes `allowedTools` (dcode never reads `enabledTools`, and it ignores unknown keys silently, so forwarding the canonical name would be a no-op), while `disabledTools` carries its own name; each entry is a tool name or an `fnmatch` glob, and import lifts `allowedTools` back. Upstream rejects a server that sets **both** filters and rejects an **empty** list, and each case is resolved the way that does not hand the model more tools than your canonical config allows. Setting both is valid canonically — other targets apply the two lists independently — but has no form here, so the **server is skipped** with a warning rather than written without filters, which would leave it running with every tool including the denied ones. An empty `enabledTools` likewise **skips the server**, since an allowlist of nothing means no tools at all and dropping the key would publish all of them. An empty `disabledTools` is the one genuine no-op, so only that key is dropped (with a warning) and the server is still written. **Server shapes:** dcode validates each entry into FastMCP 4's typed server models, so three canonical shapes are normalized as well. An `httpUrl` is written as `url` (dcode never reads the `httpUrl` alias and would take such a server for stdio) with the transport pinned to `http` when none is set, since `httpUrl` names a streamable HTTP endpoint (an authored `type` or `transport` is kept); when both are set, `url` wins and `httpUrl` is dropped. An array `command` is split into its first element and an `args` list that the rest is prepended to, because FastMCP accepts only a string `command`; an empty array leaves nothing to spawn, so that server is skipped with a warning (on a remote server the empty `command` is simply dropped). A server with `disabled: true` is **skipped** with a warning, because dcode has no per-server `disabled` key — its only off switch is the server viewer's `[mcp].disabled_servers` toggle in `config.toml` — and writing the key would leave the server running; `disabled: false` is simply dropped. See the [MCP tools docs](https://docs.langchain.com/oss/deepagents/code/mcp-tools).\n\n> **Qwen Code note:** MCP servers are written to the `mcpServers` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global, via `--global`). Qwen supports stdio (`command`/`args`), SSE (`url`), and HTTP (`httpUrl`) transports. Rulesync maps the canonical per-server `enabledTools` ⇄ Qwen's `includeTools` (allowlist) and `disabledTools` ⇄ Qwen's `excludeTools` (denylist). Other top-level keys in `settings.json` are preserved on round-trip. In project mode, import also reads the Claude-parity `.mcp.json` at the project root (loaded by Qwen Code since v0.18.1) and merges its servers beneath those of `.qwen/settings.json`, so a server defined in both keeps its `settings.json` definition, matching Qwen Code's precedence (user settings < project `.mcp.json` < workspace settings); generation still writes only `.qwen/settings.json` and never touches `.mcp.json`, so after an import and a regenerate the imported servers live in both files and the `settings.json` copy wins — edit them through `.rulesync/mcp.jsonc` from then on. A malformed `.mcp.json` is skipped with a warning. Qwen's server-level allow/deny lists — `mcp.allowed` and `mcp.excluded` in `settings.json`, MCP server names or `*` / `?` globs over the `mcpServers` keys, with `excluded` winning when a server matches both — are authored as `allowed` / `excluded` in the `qwencode` block of `.rulesync/mcp.jsonc` (for example `\"qwencode\": { \"allowed\": [\"my-trusted-server\", \"*-internal\"], \"excluded\": [\"experimental-server\"] }`) and imported back into that block. They gate every server Qwen Code loads, including ones rulesync did not write. Each list is written only when authored, so a list you leave out of the block — and the other keys of the `mcp` object, such as `mcp.serverCommand` — keep whatever value `settings.json` already holds; removing a list from the block therefore does not remove it from `settings.json`. To clear a list, author it as an empty array (`\"excluded\": []`) for one generate, or delete it from `settings.json` by hand. See the [Qwen Code MCP docs](https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/).\n\n> **Codex CLI server-name note:** Codex requires MCP server names matching `[a-zA-Z0-9_-]+`, so Rulesync auto-normalizes non-conforming names on generate (lowercase, runs of other characters become `_`, leading/trailing `_` trimmed) — e.g. `Postgres MCP - Production - Read Only` becomes `postgres_mcp_production_read_only`. If two names normalize to the same Codex name, the last processed server overwrites the earlier one (with a warning). A name with no representable characters at all (e.g. a fully Japanese name) falls back to a stable hash-derived name like `mcp_1a2b3c4d` instead of being dropped; rename the server in `.rulesync/mcp.jsonc` to pick a readable Codex name. This normalization is one-way: importing back from the generated `config.toml` yields the normalized name, not the original.\n\n> **Codex CLI key-translation note:** Codex's `[mcp_servers.<name>]` table reads its own field names, so the canonical fields are translated rather than forwarded. `headers` is written as `http_headers` (and imports back as `headers`), which Codex accepts only on a url-based server — on a stdio server it is a load error upstream, so the headers are dropped with a warning instead. The same applies to the other keys Codex's `throw_if_set` rejects for a server's transport, since one such key fails the whole `config.toml`: on a stdio server `url`, `bearer_token_env_var`, `bearer_token`, `http_headers_helper`, `env_http_headers`, `oauth`, `oauth_resource` and `auth` are dropped with a warning, and on a url-based server `args`, `env`, `env_vars`, `cwd` and `bearer_token` are. The canonical millisecond timeouts become Codex's second-based ones: `timeout` ⇄ `tool_timeout_sec` (the default timeout for tool calls) and `networkTimeout` ⇄ `startup_timeout_sec` (initialize + list-tools), dividing by 1000 on generate and multiplying on import, so a sub-second remainder is emitted as a fraction. Codex also accepts `startup_timeout_ms`, which imports verbatim into `networkTimeout` unless the config sets `startup_timeout_sec` too — Codex prefers the seconds spelling when both are present, and so does Rulesync. The canonical `tools` array is **not** written: Codex declares `tools` as a table of per-tool approval settings (`tools.<tool>.approval_mode`), and an array where it expects a table is a hard deserialization error that takes the whole server entry down, so it is dropped with a warning — use `enabledTools` / `disabledTools`, which map onto Codex's `enabled_tools` / `disabled_tools`. Codex's table itself is authored through the namespaced `codexcliTools` key and imports back into it (see [below](#codex-specific-per-tool-approval-modes-codexclitools)). Both timeouts must be non-negative: Codex builds a duration out of them and errors on a negative value, which fails the whole file, so such a value is dropped with a warning. Canonical fields Codex has no counterpart for (`type`/`transport` — Codex infers the transport from `command` versus `url` — plus `alwaysAllow`, `trust`, and the Kiro lists) are dropped silently; on import a server carrying a `url` and no `command` gets `type: \"http\"` restated, so a config read out of Codex still reaches the tools that branch on the transport. That restatement is one-way, like the server-name normalization: Codex's only remote transport is `streamable_http`, so a canonical `sse` (or `ws`, or `streamable-http`) server comes back from a round-trip as `http`. Fields Rulesync does not model, such as `env_http_headers` and `bearer_token_env_var`, pass through under their own names.\n\n> **Codex CLI environment-variable note:** Codex does not expand `${VAR}` references in `url` or `http_headers` — the values are sent literally. On generate, Rulesync therefore rewrites the two header shapes Codex can source from the environment: `Authorization: Bearer ${VAR}` becomes `bearer_token_env_var = \"VAR\"`, and a header whose whole value is `${VAR}` becomes an `env_http_headers` entry (`\"X-Api-Key\" = \"VAR\"`). The header name and the `Bearer` scheme are matched case-insensitively. A `bearer_token_env_var` or `env_http_headers` entry you set explicitly on the server wins: the header that would have been converted into it is dropped instead of being written a second time. Anything else still containing `${…}` — a reference inside a longer value, a `${VAR:-default}` fallback, or any reference in `url` (for which Codex has no environment-sourced counterpart) — is written verbatim with a warning naming the server and field, so replace it with a literal value for Codex. The rewrite is one-way: importing from Codex keeps `bearer_token_env_var` and `env_http_headers` as pass-through keys rather than restoring the canonical `headers` entries.\n\n### Codex-specific: pass shell env vars to MCP servers (`envVars`)\n\nCodex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses `envVars` (camelCase, matching the project convention used by sibling fields like `enabledTools`/`disabledTools`); the codex generator renames it to `env_vars` (snake_case) for codex's native `config.toml` format.\n\nThis is distinct from `env` (which is a literal `{name: value}` map) — `envVars` is a list of names whose **values come from the user's environment at runtime**. Both fields may coexist on the same server.\n\n```json\n{\n \"mcpServers\": {\n \"pal\": {\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\n \"--from\",\n \"git+https://github.com/BeehiveInnovations/pal-mcp-server.git\",\n \"pal-mcp-server\"\n ],\n \"envVars\": [\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"GEMINI_API_KEY\"]\n }\n }\n}\n```\n\nGenerated `~/.codex/config.toml`:\n\n```toml\n[mcp_servers.pal]\ntype = \"stdio\"\ncommand = \"uvx\"\nargs = [\"--from\", \"git+https://github.com/BeehiveInnovations/pal-mcp-server.git\", \"pal-mcp-server\"]\nenv_vars = [\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"GEMINI_API_KEY\"]\n```\n\nAn entry may also be an object naming the environment to read the variable from: `{ \"name\": \"REMOTE_TOKEN\", \"source\": \"remote\" }` reads it from the remote executor environment (and requires remote MCP stdio support), while a bare name and `\"source\": \"local\"` read from Codex's own environment. The object form is written to `config.toml` as an inline table, matching Codex's documented shape. Only `name` and `source` are accepted in that object — Codex rejects an unknown key there, and rejecting one server's entry would take the whole `config.toml` down with it, so Rulesync fails on the canonical file instead. For the same reason an entry that a `config.toml` already holds in some other shape is dropped with a warning on import rather than written into a `.rulesync/mcp.jsonc` the next generate would refuse.\n\n- Emitted only into the codex CLI output. Stripped from `RulesyncMcp.getMcpServers()` so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).\n- Use this for secrets and API keys you do not want literal-encoded into a committed `mcp.json`.\n- Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in `env` (literal value), the codex CLI behavior is upstream-defined; see the [Codex configuration reference](https://developers.openai.com/codex/config-reference#mcp_serversid-env_vars) (last checked 2026-05-13) for the exact resolution rule.\n\n### Codex-specific: run a stdio server remotely (`experimentalEnvironment`)\n\nFor stdio servers, `experimentalEnvironment: \"remote\"` starts the server through a remote executor environment when one is available. It is written as `experimental_environment` in `config.toml`. Like `envVars`, it is stripped before every other tool's MCP config is written, so it cannot leak into a config that would not understand it — and for the same reason, a server config copied straight out of a `config.toml` may spell it `experimental_environment`, which is accepted and normalized on the way to Codex.\n\nSee the [Codex MCP reference](https://learn.chatgpt.com/docs/extend/mcp) for both fields.\n\n#### Codex-specific: OAuth keys (`oauth.clientId` → `client_id`)\n\nA server's `oauth` block is preserved in the canonical Claude Code shape (camelCase `clientId`, `callbackPort`), but Codex CLI reads only the snake_case keys of its own `oauth` table: `client_id`, `client_secret`, `callback_url`, `callback_port` and `authorization_server_issuer`. Without `client_id`, `codex mcp login <server>` falls back to dynamic client registration and fails for providers that do not support it (e.g. Slack). The codex generator therefore **renames** `clientId` to `client_id` and `callbackPort` to `callback_port`, passes the Codex-native keys through (an explicit native key wins over its canonical spelling), and drops every other key, such as Claude Code's `authServerMetadataUrl`, with a warning — since Codex 0.155.0 it reports such a key as an ignored setting at startup:\n\n```toml\n[mcp_servers.slack.oauth]\nclient_id = \"1601185624273.8899143856786\"\ncallback_port = 3118\n```\n\nA value of the wrong type (a non-string client id, a `callbackPort` that is not an integer port) is dropped with a warning too, because Codex would fail to load the whole `config.toml` over it — as is a `client_secret` that is blank or comes without a non-blank client id, which Codex rejects the same way. On import, `client_id` and `callback_port` are renamed back to `clientId` and `callbackPort`; when a file carries both spellings (earlier Rulesync versions wrote `clientId` next to `client_id`), the canonical key wins so the round-trip stays stable.\n\n#### Codex-specific: transport normalization\n\nCodex reads `command` as a single string and has no `httpUrl` key, and either mismatch stops the whole `config.toml` from loading — disabling the permissions profile and approval policy along with MCP. The codex generator therefore splits an array `command` into its program plus leading `args` (canonical `args` follow), and writes `httpUrl` as `url` (an explicit `url` wins). A server whose `command` array is empty and that has no url is skipped with a warning.\n\n### Codex-specific: per-tool approval modes (`codexcliTools`)\n\nCodex reads per-tool settings from `[mcp_servers.<name>.tools.<tool>]` tables: `approval_mode` (`auto`, `prompt`, `writes` or `approve`) overrides the server's `default_tools_approval_mode` for one tool, and `output_token_limit` (a positive integer) sets that tool's output token budget. Author them as `codexcliTools`, a map keyed by tool name — usually inside the `codexcli` block, since no other tool reads it. The canonical `tools` key cannot be used for this: it is a string array, and Codex reads its own `tools` key as this table.\n\n```jsonc\n{\n \"mcpServers\": {},\n \"codexcli\": {\n \"mcpServers\": {\n \"example\": {\n \"type\": \"http\",\n \"url\": \"https://example.com/mcp\",\n \"default_tools_approval_mode\": \"writes\",\n \"codexcliTools\": {\n \"example_tool\": { \"approval_mode\": \"approve\" },\n },\n },\n },\n },\n}\n```\n\nGenerated `.codex/config.toml`:\n\n```toml\n[mcp_servers.example]\nurl = \"https://example.com/mcp\"\ndefault_tools_approval_mode = \"writes\"\n\n[mcp_servers.example.tools.example_tool]\napproval_mode = \"approve\"\n```\n\n- `codexcliTools` may also sit on a shared server; it is stripped before every other tool's MCP config is written, like `envVars`.\n- The two known fields are validated strictly, because Codex rejects the whole `config.toml` on an unknown `approval_mode` or a zero `output_token_limit`; any other key in an entry is passed through.\n- Approvals Codex itself saved into `config.toml` (for example after \"always allow\") are kept on regenerate. The authored table is merged over them field by field: a field set in `codexcliTools` wins, and every saved tool or field it does not name is kept (authoring only `output_token_limit` for a tool keeps its saved `approval_mode`). Removing a tool from `codexcliTools` therefore does not remove its entry from `config.toml`; delete it there by hand.\n- On import, the `tools` table is lifted into `codexcliTools` on the imported server. An entry Rulesync would refuse on the next generate is dropped with a warning. The table includes approvals Codex saved for you locally, so review them before committing the imported `.rulesync/mcp.jsonc`: once committed, they apply to everyone who generates from it.\n\nSee the [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference) (`mcp_servers.<id>.tools.<tool>.approval_mode` / `output_token_limit`).\n\n> **Grok CLI note:** MCP servers are written to a `[mcp_servers.<name>]` table in `.grok/config.toml` (project) / `~/.grok/config.toml` (global, via `--global`). The file is treated as shared Grok config: Rulesync only replaces the `mcp_servers` key (removing it, rather than leaving an empty table, when no server is left) and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal `env` table (it does not support the `env_vars` runtime-passthrough list), so the only field rename is `disabled` (rulesync) ⇄ `enabled = false` (grok); an active server simply omits `enabled`. A server's `disabledTools` is written to Grok's top-level `[disabled_mcp_tools]` table (`<server> = [\"tool\", ...]`, unqualified tool names) in global mode only, because Grok reads that table from `~/.grok/config.toml` alone; in project mode it is dropped with a warning. Rulesync sets or removes the entry of each server it declares and keeps every other entry (servers toggled in Grok's `/mcps` UI, and Grok's own `__managed_gateway_connectors` entry), and it imports the entries of the servers in `[mcp_servers]` back as `disabledTools`. Grok has no per-server tool allowlist, so `enabledTools` is dropped with a warning. Servers with no environment variables are emitted without a dangling `[mcp_servers.<name>.env]` table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning.\n\n### Goose-specific: MCP servers as `extensions` (global) and open-plugin manifest (project)\n\nGoose configures MCP servers in two locations depending on scope:\n\n- **Global (`--global`):** MCP servers are written as **extensions** in the shared user config `~/.config/goose/config.yaml`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `cwd` → `cwd` (stdio only), `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. A canonical server with no `command` and no `url` is **skipped with a warning** rather than written as a `stdio` extension with no `cmd`, which Goose cannot start. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. The `extensions:` map itself is co-owned: Goose's own `builtin`/`platform`/`frontend`/`inline_python` extensions (`developer`, `memory`, ...) live there alongside MCP servers and are **carried over untouched**, as is any entry Rulesync cannot read as an MCP server, while every entry it positively identifies as one (`stdio`/`streamable_http`/`sse`) is Rulesync-owned, so a server deleted from `.rulesync/.mcp.json` is retracted with a warning naming it. Import mirrors this: a non-MCP extension type is skipped with a warning instead of being imported as a server (importing a `builtin` used to strip the type that makes it work). This location supports **both stdio and remote** (http/sse) servers.\n- **Project:** Goose v1.39.0+ discovers MCP extensions in **open plugins** at `<project>/.agents/plugins/<name>/.mcp.json` (and `~/.agents/plugins/<name>/.mcp.json` at user scope). Rulesync emits `.agents/plugins/rulesync/.mcp.json`, reusing the same `.agents/plugins/rulesync/` tree already used for Goose hooks. The manifest uses the **Claude-style** `{ \"mcpServers\": { \"<name>\": { \"command\", \"args\", \"env\", \"cwd\" } } }` shape. This manifest is **stdio-only** — it cannot express `url`/`headers`, so **remote (http/sse) servers are skipped with a warning** in project mode; sync them with `--global` to `~/.config/goose/config.yaml` instead. The `.mcp.json` manifest is owned by Rulesync and is deleted when no servers remain.\n\nSee the [Goose extensions docs](https://goose-docs.ai/docs/getting-started/using-extensions/) and [open-plugins MCP PR #9471](https://github.com/aaif-goose/goose/pull/9471).\n\n### Goose-specific: commands as recipes, subagents as custom agents\n\nGoose [recipes](https://goose-docs.ai/docs/guides/recipes/recipe-reference/) are reusable YAML workflow files. **Commands** map to top-level recipes at `.goose/recipes/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global); the command body becomes the recipe `prompt`, `title` defaults to the file name and `description` to the rulesync `description` (falling back to `title`), `version` defaults to `1.0.0`, and any other recipe field round-trips through the rulesync `goose` section of a command.\n\nA recipe on disk is not invocable as `/name` on its own: Goose resolves slash commands from the `slash_commands` list in the user config (`~/.config/goose/config.yaml`), whose entries are `{ command, recipe_path }` pairs. In **global mode** Rulesync therefore registers every generated recipe there. `recipe_path` is written as an **absolute** path, because Goose resolves it with a bare `PathBuf::from(...)` on this code path (the tilde expansion used by `goose run --recipe` does not apply, so a `~/…` registration would never resolve), and the command name is lowercased, because Goose lowercases the typed command and compares it against the stored value verbatim. There is no project-level registration surface upstream, so project-scope recipes must still be run with `goose run --recipe`.\n\nThe list is co-owned: entries whose `recipe_path` points outside `~/.config/goose/recipes/` — and sub-recipes under `recipes/subagents/` — are carried over untouched, while **every** entry pointing directly into that directory is Rulesync-owned and recomputed on each `--global` generate. That retracts a deleted command's registration and drops the key once nothing is registered, but it also means a slash command you registered yourself (via Goose's own UI or `goose recipe`) for a recipe living in that directory is removed on the next generate — keep such recipes elsewhere, or author them in `.rulesync/commands/`. Command names must be unique, contain no spaces, and must not shadow a built-in command such as `/recipe`, `/compact`, or `/help`; Rulesync does not check the built-in names for you. See the [slash-command mapping in the Goose source](https://github.com/aaif-goose/goose/blob/main/crates/goose/src/slash_commands/recipe_slash_command.rs).\n\n**Subagents** map to Goose's [custom agents](https://goose-docs.ai/docs/guides/context-engineering/custom-agents/) (v1.34.0+): Markdown files with `name` (required) / `description` / `model` frontmatter whose body is the agent instructions, invocable via `@name` or delegation. They are emitted to the goose-specific discovery dirs `.goose/agents/<name>.md` (project) and `~/.config/goose/agents/<name>.md` (global), so the output cannot collide with a future shared `.agents/agents/` target; `model` and unknown future fields round-trip through the rulesync `goose` subagent section. Earlier rulesync versions emitted subagents as sub-recipe YAML under `.goose/recipes/subagents/` — a location Goose's agent discovery never scans, so those files were inert; they are no longer generated (stale outputs stay gitignored but are not cleaned up automatically).\n\n### Vibe-specific: stdio `cwd` and MCP `[auth]` block\n\nVibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory), a structured per-server `auth` block (Vibe v2.15.0+), and the four keys Vibe's `/mcp` panel writes back when you toggle a server or one of its tools — `prompt`, `sampling_enabled`, `disabled` and `disabled_tools`. Because `mcp_servers` is replaced as a whole array on each generate, a server Rulesync writes is seeded from the on-disk entry of the same name for exactly those keys, so a toggle you made in the TUI survives — unless your `.rulesync/mcp.json` states the value itself, which wins. `disabled_tools` is the canonical `disabledTools` under Vibe's spelling; `prompt` and `sampling_enabled` have no canonical equivalent and pass through as-is. The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. Servers added outside Rulesync — through `vibe mcp add` (v2.23.0) or the `/mcp add` panel, both of which persist straight into this TOML — are preserved after the managed entries instead of being deleted by the array replace. The flip side: removing a server from `.rulesync/mcp.jsonc` no longer removes it from `config.toml`; delete it there too (or run `vibe mcp remove`). Deleting it from one scope may not be enough either: since v2.24.0 Vibe stacks the user and project layers and union-merges `mcp_servers` by name, so a server of the same name left in `~/.vibe/config.toml` still resolves after you remove it from the project file. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/models.py`). The `vibe-plugin` target instead writes a plugin bundle's `mcp.json` in the Agent Plugins shape (project scope only), with stdio and streamable-http servers only. See the [plugin packaging guide](../guide/plugin-packaging.md#vibe-code-plugins).\n\n> **GitHub Copilot (VS Code) MCP note:** the `copilot` target writes `.vscode/mcp.json`, which has three documented top-level sections: `servers`, `inputs` (secret prompts referenced as `${input:id}`) and `sandbox` (filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces only `servers`; the rest of the document — including any future top-level section — is read back and preserved on each generate. VS Code recommends committing this file, so dropping an `inputs` entry would leave `${input:…}` unresolvable and the affected servers would fail to start. If the existing file cannot be parsed, generate fails with an error rather than overwriting it. See the [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration).\n\n> **Rovo Dev CLI MCP note:** Rovo Dev documents the per-server transport key as `transport` (`stdio` | `http` | `sse`), not the canonical `type`. Rulesync translates on the way out (`local` → `stdio`, `streamable-http` → `http`) and back on import; `ws` has no Rovo Dev equivalent, so those servers are skipped with a warning, and a `transport` value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. `disabled` is stripped from the servers that are written, since `mcp.json` is not where a Rovo Dev server is switched on and off — see the toggle handling below. `mcp.json` is written at both scopes: the global `~/.rovodev/mcp.json`, and in project mode the repo-committed `.rovodev/mcp.json` the Bitbucket Cloud Agentic Pipelines guide documents (pointed at via `mcp.mcpConfigPath`, which Rulesync now writes into the project `config.yml` for you: Rovo Dev's `mcpConfigPath` default points at the _global_ MCP file, so without the pointer the generated project `mcp.json` is never read. The pointer names one config instead of merging with the default, so it is written only when the project has a Rovo Dev server to run — one that targets `rovodev`, is not disabled, and names a `command`/`url` to start — since otherwise the project would trade the user's global servers for an empty file; writing it is logged, being the step that makes Rovo Dev start those servers. If the last such server is later removed or switched off, the pointer is not taken back out (Rulesync cannot tell its own past value from a user who typed the same string) but the now-empty result is reported with a warning. A `mcpConfigPath` already aimed somewhere else is left in place — it is the user's choice — and reported with a warning naming the file that stays unread. One value earns its own message in global scope: `~/.rovodev/mcp_config.json`, the default the settings reference documents, which Rovo Dev may therefore have written itself rather than the user choosing it. That is exactly the user this pointer exists for, so the warning says what to change and whether that file holds servers they would lose by changing it. The comparison recognizes the other spelling of the same home-directory file — an already-expanded absolute path — so a pointer that already names the generated file is not reported as unread, and a stale one still is. `$HOME/...` and `${HOME}/...` are deliberately not counted as correct: nothing in Atlassian's documentation says Rovo Dev expands environment variables in this setting, and a pointer that resolves literally reads no MCP servers at all, so that spelling gets a warning of its own asking for the `~` form instead. Global scope gets the same treatment, with the home-anchored value `~/.rovodev/mcp.json`, because Atlassian's own pages disagree about the default: the [settings reference](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) documents `~/.rovodev/mcp_config.json`, while the [MCP guide](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/) has servers registered in `~/.rovodev/mcp.json`. Under the first spelling the global file Rulesync writes is never read, so naming it explicitly is what makes the outcome the same either way; the value has to be home-anchored rather than repo-relative, since `~/.rovodev/config.yml` is read from whatever directory Rovo Dev runs in. That same disagreement is why the global pointer is **withheld** when `~/.rovodev/mcp_config.json` holds servers of its own — if the settings reference is the spelling in force, those are the servers Rovo Dev is running today, and naming `mcp.json` instead would stop them being read on every project on the machine. Rulesync does not write that file, so it could neither import them first nor put them back; it reports what it found and leaves the pointer for you to set once you have moved what you want to keep into `.rulesync/mcp.jsonc`. A `mcp_config.json` that cannot be parsed, cannot be read (a directory or a permission error at that path, which costs this one decision rather than aborting the run), or carries a shape Rulesync does not recognize all count the same way, since none of them can be shown to be empty; only an `mcpServers` map with no entries — or a literally empty `{}` — releases the pointer. Each of those reports also says what to do when the file turns out to hold nothing you need — set `mcp.mcpConfigPath` yourself, which Rulesync never overwrites, or change it if it is already set — so a file Rulesync cannot see inside is never a dead end. Writing the global pointer is a warning rather than a note, the project pointer changing one repository where this one changes Rovo Dev everywhere. The project file is not gitignored, since committing it is the point). A server the canonical config marks `disabled: true` is no longer dropped: its definition is written to `mcp.json` (minus the flag, which the file cannot express) and its name goes to `mcp.disabledMcpServers` in the sibling `config.yml` — the key Rovo Dev actually consults — where rulesync owns the toggle for the servers it manages while user keys (`allowedMcpServers`, ...) and disabled names for unmanaged servers survive. On import, names listed in `mcp.disabledMcpServers` come back as `disabled: true` on the matching servers; a `config.yml` that exists but cannot be parsed fails both directions closed (the import errors instead of silently re-enabling servers, and generate skips disabled definitions it cannot switch off). Since the project `mcp.json` is committed, prefer env-var references over literal credentials in server `env`/`headers`; note that rulesync owns the `mcpServers` map in that file, so servers hand-added there (rather than to `.rulesync/mcp.jsonc`) are replaced on the next generate. Rovo Dev's per-server `enable_instructions` — which decides whether the server's own initialization-response instructions join the agent's system prompt — is authored as the Rulesync-only key `rovodevEnableInstructions` (Rovo Dev's own `enable_instructions` spelling is accepted too, so an entry copied out of Atlassian's docs works) and written out as `enable_instructions`; on import it is lifted back onto `rovodevEnableInstructions`. It is a **tri-state**, not a switch that only exists when on: as of the [2026-09-02 revision](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/) of Atlassian's MCP page, an absent key or `true` surfaces the instructions and `false` is what suppresses them — the opposite of the opt-in wording the key was first documented under. So both booleans are written and both are lifted, and `false` above all: it is the one value that keeps a third-party server's text out of the prompt, and dropping it would leave the suppression unauthorable and make an import → generate round-trip switch it back on. Leaving the key out authors nothing, matching Rovo Dev's own default. A non-boolean is not read as a suppression in either direction — Rovo Dev surfaces the instructions for anything that is not `false` — and when both spellings are present on one entry the canonical key decides, so an explicit `rovodevEnableInstructions: false` overrides an `enable_instructions: true` left over from a copied example rather than losing to it. Generate emits no warning for this key: writing `true` now restates the upstream default rather than widening anything. Like `musecodeMode`, the key is stripped from every other target's output — and here that matters more than tidiness: it decides whether a third-party server's text joins the model's prompt, so it must not reach a tool you were not writing about. See the [Rovo Dev MCP docs](https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/).\n\n> **Meta Muse Code MCP note:** Muse Code reads MCP servers only from the `mcp_servers` block of the **global** user settings file `~/.config/muse/settings.json` — no project-scoped MCP location is documented, so the `musecode` MCP target requires `--global`. Since 0.2.1 Muse Code also accepts the ecosystem-standard `mcpServers` spelling: a settings file that already uses only `mcpServers` is imported from and written back under that key, so Rulesync never adds a second MCP block beside it; a file carrying both is read and written through `mcp_servers` with a warning (which block Muse Code reads then is undocumented), leaving `mcpServers` untouched. Each server entry carries a `transport` discriminator: `stdio` servers get `command` (a single string), `args`, `env` and `cwd` (the server's working directory, honored since 1.2.1), and remote servers become `transport: \"streamable_http\"` with `url`/`headers` (Muse Code's only documented remote transport). A server that states `sse` or `ws` — or carries a `ws://`/`wss://` URL with no stated type — is skipped with a warning rather than rewritten onto a transport it does not speak. A canonical `disabled: true` maps to Muse's `enabled: false` and back. The canonical millisecond timeouts become Muse Code's per-server seconds fields — `timeout` → `tool_timeout_sec` (tool calls, resource reads and prompt fetches) and `networkTimeout` → `startup_timeout_sec` — rounded up to whole seconds, since fractional seconds are not documented; only positive values are written. On import both are multiplied back to milliseconds, and a non-positive or non-numeric value is dropped with a warning rather than copied into other targets' configs. The settings file must carry `\"schema_version\": 1` — Muse Code fails startup with `malformed settings file` without it — so Rulesync bootstraps the key when it creates the file and preserves an existing value; every other settings key is preserved on round-trip and the file is never deleted. There are no per-server tool allow/deny lists. Muse Code's per-server `mode` — `required` (its default) aborts the whole run when the server fails to start, `optional` skips it with a warning — is authored as the Rulesync-only key `musecodeMode` and written out as `mode`; on import a `mode` of `required` or `optional` is lifted back into `musecodeMode`, while any other value is dropped with a warning naming the server — keeping it would copy a Muse Code key into every _other_ target's generated config, since Rulesync's server schema is loose, and the next generate would drop it from the Muse Code side regardless. `musecodeMode` is stripped from every other target's output, so it is safe to leave on a shared server entry. See the [Muse Code extending docs](https://dev.meta.ai/docs/muse-code/extending.md) and [configuration docs](https://dev.meta.ai/docs/muse-code/configuration.md).\n\n> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`; `sse`, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto `http` pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. The canonical `disabledTools` list is written as the v2 line's per-server `disabled_tools` (raw `tools/list` names the server's tools are filtered by) and read back on import; Reasonix has no per-server tool allowlist, so `enabledTools` is not emitted. Blank tool names are dropped with a warning on generate, and on import only the string entries of the list are kept. The legacy v1 line has no such key and ignores it on load. The `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with `default_tools_approval_mode`, `tools.<raw>.approval_mode` and `approvals_reviewer` — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical `mcpServers` that every MCP target writes out, so it would surface in `.mcp.json` and the rest. Note that Rulesync owns the `plugins` key, so the next generate drops the key from an older `reasonix.toml` as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (`ws`, including a `ws://`/`wss://` URL that states no transport at all) is skipped with a warning rather than written as a `type` its loader rejects. Each entry also supports `startup_timeout_seconds` (a per-server cap on the background launch/authorization/`initialize`/`tools/list` sequence, overriding the global `mcp_startup_timeout_seconds`; `0` means defer to that global cap, and is preserved rather than dropped), `call_timeout_seconds` (a per-server MCP call timeout), `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name), `concurrency` (`serial` stops two calls to that server from running at once; with no value Reasonix substring-matches the server name against a known-stateful list — `browser`, `playwright`, `puppeteer`, `chrome`, `chromium`, `selenium` — and serializes a match, so an explicit value is what settles the question in either direction: `serial` for a stateful server the list does not catch, `parallel` for one it does), `auto_start` (`false` switches the server off rather than deferring it: Reasonix drops it from the enabled set, so its tools never reach the model and it is reported as `disabled` rather than `deferred`, and only an explicit re-enable — recorded in `mcp-activation.json`, which outranks the file — brings it back), `load` (v2: `always` puts the server's tools in the schema from session start; empty or `deferred` reaches them through `use_capability`) and `oauth_allow_missing_pkce_metadata` (v2: lets OAuth proceed with an authorization server that does not advertise PKCE methods; Reasonix honours it only from the user config, so it has no effect in a project `reasonix.toml`, and rulesync names it in a warning whenever it writes `true`). All of them round-trip as passthrough fields on the canonical MCP server object rather than through a deep mapping, except `auto_start`, which canonical `disabled` drives. A canonical `disabled: true` is always written as `auto_start = false` — a stop asked for across every tool wins over an authored `auto_start`, with a warning when that was anything but `false` — and an authored `auto_start` applies only to a server that is not disabled. On import, `auto_start = false` becomes canonical `disabled: true` and `auto_start` itself is not kept (`true` is the default), so a later edit to `disabled` is not shadowed by a leftover value. `startup_timeout_seconds`, `call_timeout_seconds`, `tool_timeout_seconds`, `concurrency`, `auto_start`, `load` and `oauth_allow_missing_pkce_metadata` are type-checked on generate. A value of the wrong type would make Reasonix fail to load the whole config file, so it is dropped with a warning instead of being written. The two scalar timeouts must be integers and `tool_timeout_seconds` a table of integers, because Reasonix decodes them into `int` and a fractional number would be written as a TOML float. A `concurrency` string other than `serial` / `parallel` (compared case-insensitively, as Reasonix does) is dropped with a warning too, since Reasonix ignores it and falls back to the server-name default. `concurrency`, `call_timeout_seconds`, `tool_timeout_seconds`, `load` and the OAuth key have no canonical counterpart at all; the activation store can still flip a server independently of the file, which is runtime state rather than config; and `startup_timeout_seconds` has a near-equivalent in canonical `networkTimeout` (which Codex CLI deep-maps to its `startup_timeout_sec`), but canonical timeouts are milliseconds while Reasonix takes seconds, and Reasonix's meaningful `0` has no canonical spelling — so mapping it would either invent a value or lose one. Vibe's `startup_timeout_sec` passes through for the same reason. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema).\n\n> **ZCode MCP note:** ZCode reads MCP servers from the `mcp.servers` block of its own JSON config: `<project>/.zcode/config.json` (project) and `~/.zcode/cli/config.json` (global, via `--global`). A stdio server carries `command`, `args` and `env`; a remote server carries `type` — `http` (what rulesync calls `streamable-http`) or `sse` — plus `url` and optional `headers`. ZCode's own page shows a JSON example for stdio servers only and describes remote ones through its UI (\"Service URL\" plus optional headers), so the remote JSON spelling used here is inferred from the configuration Z.ai documents for the other MCP clients it supports. A server with no stated transport and an `http(s)` URL is written as `type: \"http\"`, ZCode's default remote transport, while a `ws`/`wss` server is skipped with a warning rather than rewritten onto a transport ZCode does not implement. ZCode's per-server toggle is `enable`, which defaults to `true` when absent, so a canonical `disabled: true` is written as `enable: false` and lifted back on import. The file is shared ZCode config: rulesync only replaces the `mcp` key (preserving its non-`servers` siblings and every other top-level key such as `model`), never deletes the file, and fails closed rather than overwriting a `config.json` it cannot parse. ZCode's legacy `.agents/mcp.json` fallback — consulted only while the `.zcode` file of the same scope lists no server — is neither written nor imported, so a hand-maintained one is left alone. Because rulesync replaces the whole `servers` map, a server added to `config.json` by hand is dropped on the next `generate`; add it to `.rulesync/.mcp.json` instead. The project file is committable, so prefer `env` entries that reference environment variables over literal credentials. There are no per-server tool allow/deny lists. See the [ZCode MCP services docs](https://zcode.z.ai/en/docs/mcp-services). The `zcode-plugin` target writes the same server shape into a plugin bundle's `.mcp.json` under `mcpServers` (project scope only), with a disabled server written as `enabled: false`, the plugin loader's spelling. See the [plugin packaging guide](../guide/plugin-packaging.md).\n\n> **IBM Bob MCP note:** IBM Bob reads MCP servers from the `mcpServers` map of a dedicated file: `<project>/.bob/mcp.json` (project) / `~/.bob/settings/mcp.json` (global, via `--global`); the project entry wins when both define the same server name. Rulesync writes the shape Bob IDE documents: a stdio server carries `command` and `args`; a streamable-HTTP server (canonical `http`, `streamable-http`, a bare `url`, or Claude-style `httpUrl`) is written as `type: \"streamable-http\"` plus `url`; an explicit `sse` server is written as a bare `url` with no `type`, Bob's legacy SSE spelling. `env`, `cwd`, `headers`, `timeout`, `alwaysAllow`, and `disabled` are passed through. The canonical `oauth` object is flattened onto the keys Bob reads: `oauth: true` plus `clientId`, `clientSecret` and `scope` (a flat key already on the server wins over the nested one; nested keys Bob has no counterpart for, such as `callbackPort`, are dropped), while a boolean `oauth` passes through as Bob's own on/off switch. On import the flat keys are gathered back into the `oauth` object when `oauth` is `true` or absent; `oauth: false` leaves the entry as authored. A server Bob cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. On import, Bob IDE's `type: \"streamable-http\"`/`sse` entries pass through, a bare `url` becomes `type: \"sse\"`, and Bob Shell's `httpURL` spelling becomes `url` with `type: \"http\"` (Bob Shell's own user file, `~/.bob/mcp_settings.json`, is neither written nor imported). Only the `mcpServers` key is rewritten, so other keys in an existing file are preserved. The global `~/.bob/settings/mcp.json` is created and edited by Bob IDE's own MCP settings UI and is therefore never removed by `--delete`. See the [Bob IDE MCP docs](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob), the [Bob MCP OAuth docs](https://bob.ibm.com/docs/ide/configuration/mcp/mcp-oauth) and the [Bob Shell MCP docs](https://bob.ibm.com/docs/shell/configuration/mcp/mcp-bobshell).\n\n> **Tabnine CLI MCP note:** Tabnine CLI reads MCP servers from the `mcpServers` key of the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global, via `--global`). A stdio server carries `command` and `args`; a remote server carries `url` plus an optional `type` — `\"http\"` (streamable HTTP; the canonical `streamable-http` alias and the Claude-style `httpUrl` field are folded into it) or `\"sse\"`. A bare `url` with no stated transport is written without `type`, since Tabnine then tries streamable HTTP first and falls back to SSE on its own. Rulesync maps the canonical per-server `enabledTools` ⇄ Tabnine's `includeTools` and `disabledTools` ⇄ `excludeTools`; `env`, `cwd`, `headers`, `timeout` and `trust` pass through. A server Tabnine cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so the other settings in the file are preserved, and the file is never removed by `--delete`. See the [MCP server config docs](https://docs.tabnine.com/main/getting-started/tabnine-agent/mcp-intro-and-setup/mcp-server-config).\n\n> **Snowflake Cortex Code MCP note:** Snowflake Cortex Code reads MCP servers only from the `mcpServers` map of the user-level `~/.snowflake/cortex/mcp.json` — no project-scoped MCP file is documented, so the `mcp` feature is **global-only** (`--global` is required; a project-scope run skips the target with a warning). Every server is written with an explicit `type`: a stdio server carries `type: \"stdio\"`, `command` and `args`; a remote server carries `url` plus `type: \"http\"` (the canonical `http`, `streamable-http`, a bare `url`, and the Claude-style `httpUrl` field all fold into it) or `type: \"sse\"`. `env`, `headers`, `oauth` and `timeout` pass through. A server Cortex Code cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so other keys in an existing file are preserved, and because `cortex mcp add` edits the same file it is never removed by `--delete`. See the [MCP configuration docs](https://docs.snowflake.com/en/user-guide/cortex-code/extensibility#mcp-configuration).\n\n> **Codewhale MCP note:** Codewhale reads MCP servers from the `servers` map (the `mcpServers` alias is also accepted) of `~/.codewhale/mcp.json` (global) and `.codewhale/mcp.json` (project). The project file is honored only once the workspace is trusted, and its servers override same-named user servers. Stdio servers carry `command` and `args`; remote servers carry `url` and are reached over Streamable HTTP, with `transport: \"sse\"` written only for servers declared as SSE. WebSocket servers are skipped with a warning. `enabledTools` / `disabledTools` become `enabled_tools` / `disabled_tools`. The canonical `timeout` and `networkTimeout` (milliseconds) become `execute_timeout` (the budget for each tool call) and `connect_timeout` (spawn, `initialize` and the first `tools/list`), which Codewhale reads as whole seconds: the value is rounded up to the next second, a negative or zero value is dropped with a warning (Codewhale has no \"no timeout\" value and fails at once on a zero budget), and an explicit `execute_timeout` / `connect_timeout` on the same server wins. Import multiplies them back into milliseconds; `read_timeout` has no canonical counterpart and passes through unchanged. Other top-level keys (such as `timeouts`) and whichever server-map key the file already uses are preserved. Because Codewhale's own `/mcp` commands also edit the file, `--delete` never removes it.\n\n> **Continue MCP note:** Continue reads every `*.json` file under `.continue/mcpServers/` (project) / `~/.continue/mcpServers/` (global, via `--global`); Rulesync writes the documented `mcp.json` there as plain JSON with an `mcpServers` map (an existing file is read as JSONC), and other files in the directory are untouched. A stdio server carries `type: \"stdio\"`, `command`, `args` and the optional `env` (`envFile` is dropped with a warning because Continue ignores it, and `cwd` and `timeout` are dropped with a warning because Continue's JSON schema has no place for them); a remote server carries `url` plus `type: \"sse\"` or `type: \"http\"` (the canonical `streamable-http` alias and a bare `url` both fold into `http`), with `headers` passed through (`oauth` and `timeout` are dropped with a warning for the same reason). A server Continue cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the `mcpServers` key is rewritten, so other top-level keys in an existing `mcp.json` are preserved. The project file is removed by `--delete`; the global one, which `~/.continue/` shares with hand-managed servers, is not. See the [MCP docs](https://docs.continue.dev/customize/deep-dives/mcp).\n\n> **CodeBuddy Code MCP note:** CodeBuddy Code reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope — the same file Claude Code reads) and of `~/.codebuddy/.mcp.json` (global, via `--global`). Those are the recommended file names; the deprecated `mcp.json` spellings and the legacy `~/.codebuddy.json` are only read by CodeBuddy when the recommended file is absent, and the per-project local scope kept in `~/.codebuddy.json` is left to the tool. CodeBuddy's server keys (`type` — `stdio`, `sse` or `http` —, `command`, `args`, `env`, `url`, `headers`, `description`) are canonical, so both scopes are written in Claude Code's pass-through shape (only rulesync-only fields are stripped) and `--targets claudecode,codebuddy` yields the same project `.mcp.json` bytes whichever target generates last (in project mode both apply the `claudecode`, `codebuddy`, `commandcode` and `qoder` blocks, see the alias groups above). Only the `mcpServers` key is rewritten, so other top-level keys such as `disabledMcpServers` are preserved (an existing file is read as JSONC); the canonical `disabled` flag is passed through as written rather than translated into `disabledMcpServers`. The project file is removed by `--delete`; the global one is not. See the [MCP docs](https://www.codebuddy.ai/docs/cli/mcp).\n\n> **Command Code MCP note:** Command Code reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope — the same file Claude Code reads, meant to be committed) and of `~/.commandcode/mcp.json` (global, via `--global`); the per-machine local scope under `~/.commandcode/projects/` is left to the tool. Because the project file is the one the `claudecode` target writes, project mode writes it in exactly Claude Code's pass-through shape — the canonical servers as they are, only rulesync-only fields stripped — so `--targets claudecode,commandcode` yields the same bytes whichever target generates last (and both apply both the `claudecode` and `commandcode` blocks, see the alias groups above). Command Code reads that shape natively: `type` is an alias of `transport`, a bare `url` means `http`, and keys it does not know (`disabled`, `timeout`) are ignored as they are by Claude Code. Only the global file, which is Command Code's own, is written in its documented spelling: every server carries the `transport` key — a stdio server `transport: \"stdio\"`, `command`, `args` and the optional `env`; a remote server `url` plus `transport: \"http\"` (the canonical `streamable-http` alias and a bare `url` both fold into `http`) or `transport: \"sse\"`, with `headers`, `env` and `oauth` passed through. There a server marked `disabled: true` is written with Command Code's native `enabled: false` and read back as `disabled: true`, other keys (`timeout`, rulesync-only fields) are left out, and a server Command Code drops at load time — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a form the tool would silently ignore. At both scopes only the `mcpServers` key is rewritten, so other top-level keys in an existing file are preserved (an existing file is read as JSONC). The project file is removed by `--delete`; the global one, which `~/.commandcode/` shares with hand-managed servers, is not. See the [MCP docs](https://commandcode.ai/docs/mcp).\n\n> **Qoder MCP note:** Qoder reads MCP servers from the `mcpServers` map of `.mcp.json` at the project root (project scope, the file its docs describe as usually committed — the same one the `claudecode` and `commandcode` targets write) and of `~/.qoder/settings.json` (global, via `--global`); the per-machine `.qoder/settings.local.json` is left to the user. Qoder uses the same server keys as rulesync (`command`, `args`, `env`, `type`, `url`, `headers`), so servers are written as they are, with only rulesync-only fields stripped; in project mode the output is byte-identical to the `claudecode` target's, and all four targets (`claudecode`, `claudecode-legacy`, `commandcode` and `qoder`) apply the `claudecode`, `commandcode` and `qoder` blocks (see the alias groups above). Other top-level keys are kept: in `~/.qoder/settings.json` only `mcpServers` is rewritten, a file that fails to parse is an error rather than being replaced, and that file is never removed by `--delete`, while the project `.mcp.json` is. See the [MCP docs](https://docs.qoder.com/en/cli/04-extending-qoder-cli/mcp-servers).\n\n> **Crush MCP note:** Crush reads MCP servers from the `mcp` key of its shared JSON config: `<project>/crush.json` (project) / `~/.config/crush/crush.json` (global, via `--global`). Crush's schema requires a `type` on every server, so one is always written: a stdio server carries `type: \"stdio\"`, `command`, `args` and `env`; a remote server carries `url` plus `type: \"http\"` (the canonical `http`, `streamable-http`, a bare `url`, and the Claude-style `httpUrl` field all fold into it) or `type: \"sse\"`, with `headers` passed through. The canonical per-server `disabledTools` ⇄ Crush's `disabled_tools` and `enabledTools` ⇄ `enabled_tools` (both match the server's own tool names, without the `mcp_<server>_` prefix Crush uses in its permissions list); `disabled` and `timeout` (which Crush reads as an integer number of **seconds**, so it is forwarded as authored and a fractional value is rounded up) map onto the same-named fields, and Crush's OAuth fields and `sessionless` marker are authored under the Crush-only keys `crushOauth`, `crushOauthClientId`, `crushOauthClientSecret`, `crushOauthCallbackPort` and `crushSessionless` (written as `oauth`, `oauth_client_id`, `oauth_client_secret`, `oauth_callback_port` and `sessionless`; the raw `oauth_client_id`, `oauth_client_secret`, `oauth_callback_port` and `sessionless` spellings are honoured as a fallback for an entry copied out of a `crush.json`, but only `crushOauth` turns the flow on, because the canonical `oauth` key is Claude Code's `{ clientId }` object and a raw `oauth: true` would be forwarded to other tools as well). Like Codex's `envVars`, these keys are stripped from what every other tool sees, so the client secret never lands in another tool's config. A server Crush cannot start or reach — one with no transport, a remote transport without a URL, a `ws`/`wss` URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form; an existing `oauth_token`, which Crush persists at runtime, is never imported. Note that `headers` and `oauth_client_secret` (as `crushOauthClientSecret`) are copied verbatim into `.rulesync/mcp.json` on import, so move any literal secret into an environment-variable reference (`$VAR`) before committing that file. At project scope Crush reads both `.crush.json` and `crush.json` and merges them (objects recursively, lists concatenated, `.crush.json` on top), so neither file hides the other. When a `.crush.json` already exists it is the file written (otherwise `crush.json`); if the `crush.json` next to it still carries the keys rulesync owns — for example from a generate run before `.crush.json` was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only `~/.config/crush/crush.json` is read. Only the `mcp` key is rewritten, so the `providers`, `options`, `permissions` and `hooks` keys in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by `--delete`. The JSON config is documented as deprecated in favour of the Bash-based `crushrc`, but it stays supported and is the schema Crush publishes; a `crushrc` next to it compiles `mcp add` into the same `mcp.<name>` entries and wins key by key. See the [config docs](https://github.com/charmbracelet/crush/blob/main/docs/config/README.md#mcp-servers).\n\n> **Codebuff (Freebuff) MCP note:** Codebuff — renamed to Freebuff; the `codebuff` and `freebuff` CLIs share one configuration — reads MCP servers from the `mcpServers` map of `.agents/mcp.json` (project) / `~/.agents/mcp.json` (global, via `--global`). Codebuff validates every server against a strict schema and ignores the whole file when one server fails, so only the keys it accepts are written: a stdio server as `type: \"stdio\"`, `command` (an array `command` is split into `command` and leading `args`), `args` and `env`; a remote server as `type` (`http`, or `sse`; `streamable-http` becomes `http`), `url` (`httpUrl` is folded into it), `headers` and `params`. Other keys (`cwd`, `timeout`, tool filters, ...) are dropped with a warning, and a `disabled: true` or WebSocket (`ws`) server is skipped with a warning. Codebuff treats every `env` value that starts with `$` as a variable name and ignores the whole file when that variable is unset, so a whole-value canonical `${NAME}` is written as `$NAME` (and converted back on import), a server with any other `env` value starting with `$` (such as `${HOST}:8080` or `${VAR:-default}`) is skipped with a warning, and a reference later in a value, or in `headers`, is left as literal text. A `params` map whose values are not all strings is dropped with a warning. The global `~/.agents/mcp.json` is never deleted by `--delete`, since `~/.agents/` is shared with other tools. Codebuff asks before loading a repository's `.agents/` directory for the first time (`CODEBUFF_TRUST_AGENT_DIRS=1` or `--trust-agents` skips the prompt, e.g. in CI). Codebuff also reads `AGENTS.md` / `CLAUDE.md` and `.agents/skills/`, so use the `agentsmd` and `agentsskills` targets for rules and skills; its TypeScript agent definitions under `.agents/*.ts` are not generated. See the [MCP docs](https://www.codebuff.com/docs/tips/mcp-servers).\n\n## `.rulesync/.aiignore` or `.rulesyncignore` (deprecated)\n\n> **Deprecation notice:** The `ignore` feature is deprecated in favor of the more expressive [`permissions` feature](#rulesync-permissions-jsonc). Existing ignore configurations, generation, import, conversion, and explicit `rulesync add ignore` scaffolding remain supported throughout Rulesync 14.x. Removal, if any, will be decided separately and will not occur before a future major release. `rulesync init` no longer enables or scaffolds ignore for new projects.\n\nRulesync continues to support a single legacy ignore list in either location:\n\n- `.rulesync/.aiignore` (preferred legacy location)\n- `.rulesyncignore` (older project-root location)\n\nRules and behavior:\n\n- You may use either location.\n- When both exist, Rulesync prefers `.rulesync/.aiignore` over `.rulesyncignore` when reading.\n- Explicitly running `rulesync add ignore` creates `.rulesync/.aiignore` when neither location exists.\n\nExample:\n\n```ignore\ntmp/\ncredentials/\n```\n\n### Migrating to permissions\n\nMove each ignore pattern into the `read` category of `.rulesync/permissions.jsonc` with the `deny` action:\n\n```jsonc\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {\n \"read\": {\n \"tmp/**\": \"deny\",\n \"credentials/**\": \"deny\",\n },\n },\n}\n```\n\nThis is the closest replacement for preventing an agent from reading ignored paths — except for Zed, whose read-only tools are not permission-gated at all: there the replacement is `private_files`, which the ignore feature writes, so a `read` deny rule is dropped with a warning instead. If the old policy was also intended to prevent changes, repeat the patterns under `edit` and `write`. Target tools differ in the permission categories they can represent, so review the [Supported Tools and Features](./supported-tools.md) table and the tool-specific permission notes below before removing the old ignore feature from a multi-tool project.\n\n### Where ignore patterns are written per tool\n\nMost tools get a dedicated ignore file (for example `.cursorignore`,\n`.geminiignore`, `.clineignore`). Antigravity CLI is built on the same engine\nas Gemini CLI, so it reads the project-root `.geminiignore` file. Claude Code is the exception: it does not\nread a separate ignore file, so Rulesync writes the deny list into Claude\nCode's settings file as `permissions.deny` entries (`Read(<pattern>)`).\n\nReasonix has no ignore file either, so its deny list goes into the `[permissions]` table of the shared `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`) as `Read(<pattern>)` entries — the same Claude-Code-style rule syntax the permissions feature writes there. `deny` is used rather than `[sandbox].forbid_read` because deny rules take glob specifiers and are documented as \"a hard block in every mode\", while `forbid_read` takes absolute paths with no documented glob support. The file is shared with the MCP and permissions features: only `Read(...)` deny entries are replaced, every other table and deny entry is preserved, and the file is never deleted. When the permissions feature also manages the `Read` category its explicit rules win, and the overwrite is warned about. As with the MCP and permissions features, the file is re-serialized on write, so hand-written comments, blank lines, and key ordering in `reasonix.toml` are not preserved.\n\nGrok CLI has no ignore file either; its only path-deny surface is a custom sandbox profile. Rulesync writes the deny list into a rulesync-owned `[profiles.rulesync]` table (`extends = \"workspace\"` plus a `deny` array) in `.grok/sandbox.toml` (project) / `~/.grok/sandbox.toml` (global, via `--global`). Grok kernel-enforces a profile's `deny` entries for reads and writes, but **only while that profile is active** — start Grok with `grok --sandbox rulesync`, set `GROK_SANDBOX=rulesync`, or set `profile = \"rulesync\"` under `[sandbox]` in your user `~/.grok/config.toml`; generating the file alone changes nothing. Patterns are translated to the glob subset Grok accepts, because Grok refuses to start on a deny glob it cannot interpret: a leading `/` is dropped (Grok would read it as an absolute filesystem path, while relative entries are anchored at the workspace), and a pattern with no other `/` gains a leading `**/` so it still matches at any depth. A glob denies only the paths it matches itself, not what lies under a matching directory, so each glob is paired with a `<glob>/**` entry for its contents, and a directory-only pattern (trailing `/`) keeps only that entry (`*.pem` → `**/*.pem` + `**/*.pem/**`, `tmp/` → `**/tmp/**`); an exact path such as `/build/` → `build` needs no pairing, since Grok denies an existing directory together with its contents. On Linux, though, Grok expands every glob to the files that exist at launch and refuses to start once that exceeds its limit (4096 matches), so an unanchored directory pattern over a large tree (`node_modules/` → `**/node_modules/**`) can stop Grok from starting; anchor such a pattern (`/node_modules/`) to get an exact, scan-free entry instead (it covers the directory's contents on both platforms when the directory exists at launch; one created later is covered only by name on macOS). Negations (`!pattern`), brace alternation, backslash escapes, empty or `.`/`..` segments, a `**` that is not a whole path segment (`**.log`), and character classes Grok rejects (a literal `]` as the first member, nested `[` such as POSIX `[[:digit:]]`, or an unclosed `[`) cannot be expressed, so those patterns are skipped with a warning. Only the `rulesync` profile is managed: your other profiles, and any extra keys you add to the `rulesync` profile itself (such as `restrict_network` or a different `extends`), are preserved; when there are no patterns an existing profile is kept with an empty `deny` list (so a session that selects it still starts) and none is created; and the file is never deleted. Import reads back only the `rulesync` profile's `deny` list, folding each `<glob>/**` entry into its pair (or into a `pattern/`) and skipping absolute entries. Three upstream caveats apply: when `~/.grok/sandbox.toml` and `.grok/sandbox.toml` both define `rulesync` differently, Grok uses the user profile and warns at startup, so a project and a global generate with different lists do not combine; on Linux a glob is expanded to the files that exist when Grok starts, so files created later are not covered (macOS enforces globs at runtime), and Grok refuses to start when that scan exceeds its depth or size limits; and the file is re-serialized on write, so comments are not preserved. See the [Grok sandbox docs](https://docs.x.ai/build/features/sandbox).\n\nKiro reads `.kiroignore` in project scope and `~/.kiro/settings/kiroignore` in user scope. The `kiro` and `kiro-ide` targets therefore support `--global` for the deprecated ignore feature, as do `grokcli`, `reasonix` and `zed` (whose config files exist in both scopes), as well as `devin` and `kilo` (below); the remaining ignore targets are project-only. `kiro-cli` is project-only too: Kiro's [capability matrix](https://kiro.dev/docs/kiroignore/) marks the global ignore file as IDE-only, and the CLI reads only the workspace `.kiroignore`.\n\nKilo Code reads the project-root `.kilocodeignore` and, in global scope, `~/.kilocode/.kilocodeignore` — note the **legacy `~/.kilocode/` directory**, not `~/.config/kilo/` where the rest of Kilo's global config lives. Kilo loads that global file on every config load and appends the project patterns after it, so the project file wins on conflict ([`ignore-migrator.ts`](https://github.com/Kilo-Org/kilocode/blob/main/packages/opencode/src/kilocode/ignore-migrator.ts)). The `kilo` target therefore also supports `--global` for the ignore feature; like every other dedicated ignore file, the global one is replaced wholesale by the patterns from `.rulesync/.aiignore`.\n\nDevin writes the project file as `.devinignore` (reading the pre-rebrand `.codeiumignore` and `.windsurfignore` as import fallbacks, in that order — the docs list the three names side by side without defining a precedence, so the order is rulesync's own choice), and in global scope writes `~/.codeium/.codeiumignore`. Three things about that global path are worth knowing: it keeps the **legacy brand spelling** — the rename to `.devinignore` covered only the project file, so no `.devinignore` variant is written or read there, not even as a fallback; it is documented in the Devin **Desktop** docs tree rather than the Devin Local (CLI) one, which is why it sits outside `~/.config/devin` where the other global Devin paths live; and it is positioned as an **enterprise** feature for enforcing ignore rules across many repositories, not as a general per-user setting. See [the Devin ignore docs](https://docs.devin.ai/desktop/context-awareness/windsurf-ignore).\n\nZed has no ignore file: its deny list is the `private_files` array inside the shared settings file — `.zed/settings.json` in project scope and `~/.config/zed/settings.json` in global scope (`%APPDATA%\\Zed\\settings.json` on Windows). `private_files` is a worktree setting, and Zed layers default → user → project, so the key is honored in the user settings file too. The array is **owned wholesale by Rulesync**: it is replaced with the patterns from `.rulesync/.aiignore` on every generation, so a pattern deleted there is retracted from the file Rulesync writes instead of surviving forever. Note that this narrows what Rulesync's own layer contributes, not the effective set: Zed's shipped default and any other settings layer still add their own patterns (see below). When no patterns remain at all, the key is removed rather than written as `[]`. `private_files` is an `ExtendingVec`: each settings layer's value is appended to the one below it (`merge_from` calls `extend_from_slice`) instead of replacing it, and Zed ships a populated default (`**/.env*`, `**/*.pem`, …). Writing the key is therefore purely additive — an empty array would neither disable Zed's secret redaction nor mean anything at all — so omitting it is how Rulesync says it contributes nothing here. Every other key in the file — including the MCP `context_servers` and permissions `agent` blocks and unrelated editor settings — is preserved, and the file is never deleted.\n\nGoose retired `.gooseignore` upstream (\"removed some time ago in favour of other ignore things like gitignore etc\" — [goose#10343](https://github.com/aaif-goose/goose/issues/10343)), so rulesync no longer generates it; the replacement guidance is `.gitignore` plus tool permissions. Stale `.gooseignore` files from earlier versions stay gitignored but are not cleaned up automatically.\n\nCline's `.clineignore` is still emitted, but its own docs now title it \"deprecate soon\" and state it is not a security or access-control boundary — upstream's replacement direction is a Cline plugin enforcing via a `beforeTool` hook. Treat the matrix ✅ as a deprecated surface.\n\nCrush writes `.crushignore` at the project root only (no global equivalent), using the same gitignore-style patterns Crush already applies to its own built-in exclusion list in [`internal/fsext/fileutil.go`](https://github.com/charmbracelet/crush/blob/main/internal/fsext/fileutil.go).\n\nIBM Bob reads a gitignore-style `.bobignore` at the project root only (there is no user-level equivalent, so `--global` is not supported), which keeps the listed files out of Bob's context and file operations. See the [bobignore docs](https://bob.ibm.com/docs/ide/configuration/bobignore).\n\nTabnine CLI reads a gitignore-style `.tabnineignore` at the project root only (there is no user-level equivalent, so `--global` is not supported), which the CLI's file search, `@` references and directory-listing tools respect alongside `.gitignore` (the `context.fileFiltering.*` settings control this). See the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference).\n\nContinue reads a gitignore-style `.continueignore` at the workspace root and a user-level `~/.continue/.continueignore` (global, via `--global`), which keep the listed files out of codebase indexing and the `@` context providers alongside `.gitignore`. See the [codebase reference](https://docs.continue.dev/reference/deprecated-codebase).\n\nLetta Code reads `.letta/.lettaignore` at the project root only (there is no user-level equivalent, so `--global` is not supported). It only keeps the listed files out of Letta Code's `@` file search — it is not an access-control boundary, so use the `permissions` feature to deny reads. The patterns are written as-is; note that Letta Code skips `!` negation lines, strips a trailing `/`, matches a pattern containing `/` against the relative path, and matches any other pattern case-insensitively against the file or directory name. See the [settings docs](https://docs.letta.com/reference/settings/index.md).\n\nQoder reads a gitignore-style `.qoderignore` at the project root only (there is no user-level equivalent, so `--global` is not supported). Qoder documents it for the Qoder IDE, which keeps the listed files out of codebase indexing alongside `.gitignore`; it is not an access-control boundary, so use the `permissions` feature to deny reads. See the [codebase indexing docs](https://docs.qoder.com/user-guide/indexing).\n\nCodebuff (Freebuff) reads a gitignore-style `.codebuffignore` at the project root beside `.gitignore` (there is no user-level equivalent, so `--global` is not supported), which keeps the listed files out of the agent's file tree. The file keeps its Codebuff name after the rename to Freebuff, and both the `codebuff` and `freebuff` CLIs read it. The patterns are written as-is, `!` negation included. See the [source](https://github.com/CodebuffAI/freebuff/blob/main/common/src/util/project-ignore.ts).\n\nHermes Agent uses a project-local `rulesync-ignore` plugin under `.hermes/plugins/`. It applies the canonical gitignore-style patterns through [`pre_tool_call`](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/#pre-tool-call) to `read_file`, `write_file`, and `patch` before execution, and filters ignored paths from `search_files` results through `transform_tool_result`. This is defense in depth around Hermes file tools; terminal commands and paths already present in conversation context are outside the plugin's enforcement surface. Hermes deliberately requires [explicit trust for project plugins](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins/), so run it from the trusted project root with that invocation opted in:\n\n```sh\nHERMES_ENABLE_PROJECT_PLUGINS=1 hermes\n```\n\nRulesync adds `rulesync-ignore` to `plugins.enabled` in `$HERMES_HOME/config.yaml` but deliberately leaves `$HERMES_HOME/.env` unchanged. Existing configuration is preserved, explicit `plugins.disabled` conflicts fail, and `--delete` retains the additive user-level activation.\n\nFor Cursor, Rulesync emits only `.cursorignore` — the file that **blocks access\nentirely** (semantic search, Tab, Agent, Inline Edit, and `@`-mentions). Cursor\nalso supports a second file, `.cursorindexingignore`, which excludes files from\n**indexing only** while keeping them accessible to the AI on demand. These two\nfiles mean _different_ things, and Rulesync's `ignore` feature models a single\ncanonical ignore list per tool with no per-pattern distinction between\n\"block access\" and \"exclude from indexing only\". Emitting the same patterns to\nboth files would be incorrect, so `.cursorindexingignore` is intentionally **not\ngenerated** (an intentional non-goal). Author it by hand if you need\nindexing-only excludes.\n\nBy default, Claude Code's deny list is written to the **shared**\n`.claude/settings.json` so that the policy can be committed and reviewed by\nthe team. This is intentional (see issue #1094), but it means that running\n`rulesync gitignore` will not add `.claude/settings.json` to `.gitignore` —\nthat file may also contain other shared Claude config you actively want to\ncommit.\n\nIf you would rather keep the deny list out of version control, opt into the\n**local** mode using the per-feature options object form:\n\n```jsonc\n// rulesync.jsonc\n{\n \"targets\": [\"claudecode\"],\n \"features\": {\n \"claudecode\": {\n \"ignore\": { \"fileMode\": \"local\" },\n },\n },\n}\n```\n\n| `fileMode` | Output file | Tracked by git by default |\n| -------------------- | ----------------------------- | ----------------------------------------------------- |\n| `\"shared\"` (default) | `.claude/settings.json` | Yes — meant to be committed and shared with the team. |\n| `\"local\"` | `.claude/settings.local.json` | No — `rulesync gitignore` already excludes this file. |\n\nEither file gets a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as its first entry when it does not have one yet; an existing `$schema` is kept as is.\n\n## `.rulesync/permissions.jsonc`\n\n`.rulesync/permissions.jsonc` is the recommended source path and accepts comments and trailing commas. The legacy `.rulesync/permissions.json` path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.\n\nFor Hermes Agent imports, Rulesync treats a valid private `permissions.rulesync` block as provenance, then reconciles it with current native settings. `command_allowlist`, `approvals.deny`, and an enabled `security.website_blocklist` are authoritative for their mapped canonical rules (`bash` `allow`, `bash` `deny`, and `webfetch` `deny` respectively), so hand edits replace stale generated values; an `allow` in any other category, and a `bash` `allow` that generation withheld because a stricter `*` or `bash` rule covers it, are kept as the private block recorded them, since the allowlist never carried them. A config with no private block still imports those native rules. Unmodeled `approvals`, `security`, `skills`, and `memory` settings remain under the `hermes` override; unrelated root settings such as `model` are not imported.\n\n`rulesync init` scaffolds a `codexcli` block with `approval_policy: \"on-request\"`, `approvals_reviewer: \"auto_review\"`, and `base_permission_profile: \":danger-full-access\"`. On generation, the profile value becomes Codex's top-level `default_permissions`.\n\nPermissions define which tool actions are allowed, require confirmation, or are denied. The canonical format uses **lowercase tool category names** and **glob patterns** mapped to permission actions.\n\n**Permission actions:**\n\n- `allow` -- Automatically permitted without user confirmation\n- `ask` -- Requires user confirmation before execution\n- `deny` -- Blocked from execution\n\n**Supported tool categories:** `bash`, `read`, `edit`, `write`, `webfetch`, `websearch`, `grep`, `glob`, `notebookedit`, `agent`, the all-tools key `*` (a rule written under it is meant for every tool, shell commands included, so a `*` `deny` overrides a `bash` `ask` or `allow` on the same pattern, following `deny` > `ask` > `allow`; how faithfully each adapter carries it is described per tool below), and MCP-specific tool names (e.g., `mcp__puppeteer__puppeteer_navigate`)\n\nExample:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {\n \"bash\": {\n \"git *\": \"allow\",\n \"npm run *\": \"allow\",\n \"rm -rf *\": \"deny\",\n \"*\": \"ask\"\n },\n \"edit\": {\n \"src/**\": \"allow\"\n },\n \"read\": {\n \".env\": \"deny\",\n \"credentials/**\": \"deny\"\n }\n }\n}\n```\n\n**Rejected keys.** Two kinds of key are refused when the source file is read, rather than being carried into generated configs:\n\n- A **blank key** (empty, or only whitespace), whether it names a pattern or a category. A blank pattern is a prefix of every command and a substring of every path, so a tool that honors it grants or denies everything, while a tool that filters it — Roo Code keeps only entries passing `cmd.trim().length > 0` — ignores it entirely. A blank **category** is worse still: every target reads categories by name, so nothing under it reaches any tool and the rules written there are silently dead. Rather than let each target decide, Rulesync rejects both on the source file.\n- A key named `__proto__`, `constructor`, or `prototype`. Rulesync strips these from every source document it parses, because assigning them would reach the prototype chain instead of the object, so such an entry could never reach a generated file. Rulesync names the offending path instead of dropping it in silence — in `.rulesync/permissions.jsonc`, `.rulesync/mcp.jsonc`, and `.rulesync/hooks.jsonc` alike.\n\nImporting removes a blank key rather than reproducing it: keeping one would write a source file the very next `generate` refuses — and that refusal is fatal for the whole file, so a single blank key carried over would take every tool's permissions with it. This applies to blank patterns and blank categories, in the shared `permission` block and in tool-scoped `{toolname}.permission` blocks alike, since importing OpenCode or Kilo routes their tool-only categories into the tool-scoped block. The one exception is the _patterns_ inside `vibe.permission`, whose categories hold `sensitive_patterns` objects rather than pattern maps, so the keys inside them are field names and are left alone; its category names are filtered like any other. Removals are reported in warnings naming the tool config they were read from and how many keys were dropped from each block — patterns and categories reported separately, because dropping a pattern can widen what the imported configuration allows (a blanket blank pattern may have been the only entry denying anything) while dropping a category only removes rules that were never going to be generated.\n\nWhen removing a blank pattern leaves a category with no rules at all, the category itself is removed rather than left as an empty object. An empty category means \"Rulesync manages this category and it has no rules\", which makes the next `generate` delete the entries the tool's own config already had; removing the category leaves them alone. When that empties a tool-scoped block outright, the `permission` key is dropped too — and with it the whole `{toolname}` block if nothing else was authored under it — so no empty override is left behind for the next reader to puzzle over. In a tool-scoped block the removal also changes what the block means, from \"override this category with nothing\" to \"inherit the shared block\"; that is deliberate, since the alternative is the next `generate` deleting rules written by hand in the tool's own config, and every removal is warned about.\n\n**Importing merges into the existing file.** `rulesync import --features permissions` merges what it imports into an existing `.rulesync/permissions.jsonc` (or legacy `.rulesync/permissions.json`) **per category** instead of replacing the file, so importing from a second tool keeps what the first import produced. Each tool maps only some categories — Roo Code, for instance, maps shell commands to `bash` and nothing else — so importing from Roo Code after Claude Code replaces `bash` and keeps `read`, `edit`, `webfetch`, and the rest:\n\n- A category the import carries replaces the existing category wholesale: the imported tool is authoritative for the categories it maps, so patterns are never mixed across tools and a re-import from the same tool reproduces the same category. There is no pattern-level conflict to resolve; the last import of a category wins.\n- A category the import does not carry is kept, and so is every other top-level key (`$schema`, other tools' `{toolname}` blocks).\n- An imported category with no rules never replaces an existing one, since an empty category would make the next `generate` delete the rules another tool's import produced.\n- The importing tool's own `{toolname}` block (for example `claudecode` when importing from Claude Code) is replaced wholesale, and removed when the import produces none, so a setting removed from the tool's own config disappears here too. Hand-written tool-scoped categories in that block are therefore not kept across a re-import from the same tool. Another tool's block that the import carries — Hermes Agent restores them from its provenance block — merges field by field, and its `permission` record merges per category.\n- Comments in the existing JSONC file are kept wherever the edit allows. An existing file that fails validation stops the import instead of being overwritten.\n\nBecause the file on disk is the merge base, a category deleted by hand comes back only when the next import carries it again; delete it from the tool's own config too to keep it gone. The reverse holds as well: emptying a category in the tool's own config (removing every `WebFetch(...)` rule from Claude Code, say) does not remove the shared category, because the import no longer carries it and other tools may have contributed it — delete it from `.rulesync/permissions.jsonc` too. Hermes Agent is the exception to \"the tool is authoritative only for what it maps\": its import restores the whole canonical document recorded in its provenance block, so every category it recorded is replaced with the recorded value. Only the permissions import merges this way; the other features' imports are unchanged.\n\n### Tool-scoped permission blocks (`{toolname}.permission`)\n\nThe shared `permission` block applies to every targeted tool. To scope rules to a single tool, add a tool-scoped `{toolname}` block with a `permission` record of the same shape — mirroring `{toolname}.hooks` in `.rulesync/hooks.jsonc` and `{toolname}.mcpServers` in `.rulesync/mcp.jsonc`:\n\n```jsonc\n{\n \"permission\": {\n \"bash\": { \"git *\": \"allow\", \"*\": \"ask\" },\n },\n \"claudecode\": {\n \"permission\": {\n // Replaces the shared `bash` category for Claude Code only.\n \"bash\": { \"git *\": \"allow\", \"git push *\": \"deny\", \"*\": \"ask\" },\n },\n },\n}\n```\n\n- Categories are merged **per category**: a tool-scoped category replaces the shared category wholesale for that tool; shared categories it does not name still apply.\n- Any permissions-capable `--targets` name is accepted as a block key. `kiro-cli`/`kiro-ide` alias to the `kiro` key and `hermesagent` to `hermes` (matching the shared output file each writes).\n- OpenCode, Kilo, and Vibe keep their existing tool-native `permission` override semantics (bare action strings / tool-only categories / `sensitive_patterns` — see the tool-specific callouts below); their blocks are consumed by their translators instead of the central merge.\n\n#### JSON Schema Support\n\nRulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/permissions.jsonc`:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json\",\n \"permission\": {}\n}\n```\n\nFor Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` (project mode) or `~/.claude/settings.json` (global mode) using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`). The generated settings file also gets a `\"$schema\": \"https://json.schemastore.org/claude-code-settings.json\"` key as its first entry when it does not have one yet (an existing `$schema` is kept as is), so editors validate and autocomplete it.\n\nClaude Code's file permission checks match only `Edit(path)` and `Read(path)` rules: a `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule \"is accepted but never matched by those checks, so Claude Code warns at startup for each allow, deny, or ask rule in one of these unmatched forms\" ([permissions docs](https://code.claude.com/docs/en/permissions), v2.1.210+). Rulesync therefore writes a canonical `write` or `notebookedit` rule that carries a pattern as `Edit(pattern)`, and a `glob` rule as `Read(pattern)`. A rule whose pattern is `*` is a tool-name rule with no path — it matches the tool everywhere and produces no warning — so it is still written as the bare `Write` / `NotebookEdit` / `Glob`. Entries an earlier Rulesync wrote in the warned form are replaced on the next generate, and so is a rewritten entry whose action changed, so flipping a rule from deny to allow never leaves the old deny behind to win. Rewriting a rule does **not** make Rulesync claim the `Edit` or `Read` namespace as a whole: a `Read(...)` deny the [ignore feature](#rulesyncignore) wrote, or an `Edit(...)` rule you added to `settings.json` by hand, is left alone unless the canonical config manages that category itself. Import stays tolerant of both forms, so an existing `settings.json` still round-trips; a rewritten rule comes back under `edit` or `read` rather than the category it was authored in, since that is the rule Claude Code actually applies. Note that this widens a `glob` **allow** rule: `Read(pattern)` permits reading the files' contents, not just listing their names — the docs prescribe the substitution, but author `glob` allow rules with that in mind. When two categories resolve to the same entry with different actions (`edit` allowing what `write` denies, say) both are written and Rulesync warns — Claude Code applies deny first, then ask, then allow. A rule under the all-tools `*` category has no Claude Code counterpart: a rule there names one tool, and no name stands for every tool, so `{\"*\": {\"rm *\": \"deny\"}}` is written as `*(rm *)` — an entry that carries the rule back on import but matches no tool while Claude Code runs. Rulesync warns about every such rule, since an inert deny beside a `Bash` allow reads as a restriction that is not there; write the rule under the categories it is meant for (`bash`, `read`, ...) to have Claude Code enforce it.\n\n> **Claude Code-only override (`claudecode` key):** Claude Code's `permissions` object also carries non-list fields with no canonical permission category — notably `defaultMode` (the session-start permission mode: `default` | `acceptEdits` | `plan` | `auto` | `bypassPermissions`) and `additionalDirectories` (extra working directories). Claude Code ignores a `defaultMode` of `auto` or `bypassPermissions` in `.claude/settings.json` and `.claude/settings.local.json` ([permission modes](https://code.claude.com/docs/en/permission-modes#which-mode-a-session-starts-in)), so at project scope Rulesync skips those two values with a warning and writes them only under `--global`. Add a tool-scoped `claudecode` override key alongside the shared block to author them: the fields under `claudecode.permissions` are merged into the settings `permissions` object and emitted **only** for Claude Code, while the shared `permission` block continues to drive the managed `allow`/`ask`/`deny` arrays. The block is a verbatim passthrough (so other/future `permissions` fields such as the org locks `disableBypassPermissionsMode`/`disableAutoMode` can be set too), but any `allow`/`ask`/`deny` placed inside it is ignored — rulesync owns those arrays. On import, the non-list `permissions` fields round-trip back into the `claudecode` override. Note that these fields are merged **additively** into the existing `settings.json` (so hand-added settings survive): removing a field from the `claudecode` override does not delete a value already written to `settings.json` — clear it there by hand.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"claudecode\": {\n> \"permissions\": { \"defaultMode\": \"acceptEdits\", \"additionalDirectories\": [\"../shared\"] },\n> \"sandbox\": { \"network\": { \"allowedDomains\": [\"example.com\"], \"strictAllowlist\": true } },\n> \"editorMode\": \"vim\",\n> \"env\": { \"MY_VAR\": \"1\" }\n> }\n> }\n> ```\n>\n> The same override key also carries `sandbox`, the sibling top-level settings subtree governing the sandbox commands run in (`sandbox.network.*`, `sandbox.filesystem.*`, `sandbox.credentials`, `sandbox.allowAppleEvents`, ...). It has no canonical permission category either — it constrains _how_ a permitted command runs rather than which commands are permitted — so it is a verbatim passthrough on the same terms, merged into the top level of `settings.json` and round-tripped back on import — except for the three paths naming an executable, covered by the Trust caveat below, which are dropped in both directions. The merge is recursive, unlike the flat `permissions` fields above: `sandbox` subtrees carry restriction lists (`network.deniedDomains`, `filesystem.denyRead`), so setting one flag under `network` must not drop the denials beside it. A sibling key at any depth survives; a list you author replaces the existing list rather than being appended to. **Scope caveat:** Claude Code honors a subset of `sandbox.*` only from user settings, managed settings and the `--settings` flag — `filesystem.disabled`, `network.strictAllowlist`, `network.tlsTerminate`, `credentials.allowPlaintextInject`, `credentials.awsPairs`, `credentials.sigv4` and `allowAppleEvents` — and ignores them in a repository's `.claude/settings.json` / `.claude/settings.local.json`. Rulesync therefore skips those keys when generating project scope (warning once per key) and emits them only under `--global`, so it never _writes_ a project-scope sandbox policy that does nothing. The same restriction applies **per entry** inside `credentials.files` and `credentials.envVars`: an entry with `\"mode\": \"mask\"` is ignored in a repository's settings file, so Rulesync drops just those entries at project scope (warning once per list) while keeping the `deny` entries in the same list, which every scope does honor. Values already in the file are left untouched — both ones you hand-wrote and ones an earlier Rulesync version generated — because the two cannot be told apart and clobbering your file would be worse; so an inert key committed before this behavior existed stays there until you remove it by hand. Import stays scope-agnostic. See the [sandboxing docs](https://code.claude.com/docs/en/sandboxing).\n>\n> **Any other key is a top-level `settings.json` key.** Everything in the `claudecode` block other than `permission`, `permissions`, `sandbox` and `hooks` is written straight to the top level of `.claude/settings.json` and round-trips back on import, so a setting Claude Code adds needs no Rulesync release to become authorable — `editorMode`, `emojiCompletionEnabled`, `workflowSizeGuideline`, `keybindingFlavor`, `env`, `model`, `alwaysThinkingEnabled` and anything after them all work the same way. The merge is recursive, like `sandbox`, so setting one key under `env` keeps the variables already in the file. `hooks` is excluded because the [hooks feature](#rulesynchooksjsonc) owns it, `permission`/`permissions`/`sandbox` because they have their own handling above, and `$schema` because it is an editor pointer rather than a Claude Code setting. **Scope caveat:** the [settings reference](https://code.claude.com/docs/en/settings-reference) documents a scope per key, and Rulesync skips a key the file it is writing cannot honor (warning once per key): keys scoped `User or managed` / `User, local, or managed` (`spellcheck`, `autoMode`, `vimInsertModeRemaps`, `pluginConfigs`, `sshConfigs`, `syncClaudeAiSkills`, ...) are skipped at project scope and emitted only under `--global`, while keys scoped `Managed` (`allowManagedHooksOnly`, `requiredMinimumVersion`, `strictKnownMarketplaces`, ...) or `Global config` (`diffTool`, `autoConnectIde`, `copyOnSelect`, ...) are skipped in both scopes, because neither file Rulesync writes is the file that reads them — set those by hand in the managed settings file or `~/.claude.json`. Keys the settings reference marks as removed (`teammateDefaultModel` since v2.1.234, `permissionExplainerEnabled` since v2.1.257, `taskOutputMaxChars` since v2.1.277) are skipped in both scopes too, with a warning that names the removing version, since current Claude Code versions ignore them wherever they are set. The alternate spellings `additionalMarketplaces` and `allowedMarketplaces` are resolved to their canonical keys (`extraKnownMarketplaces` and `strictKnownMarketplaces`) before that check, so an alias is treated exactly as the key it spells. The scope caveat also reaches inside `env`, per variable, following the settings reference's [list of variables Claude Code ignores in `env`](https://code.claude.com/docs/en/settings-reference#variables-claude-code-ignores-in-env): the variables a checked-out repository should not control — `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `HOME`, `TMPDIR`, `TMP`, `TEMP` and `XDG_*`; the Windows `SystemRoot`, `ComSpec`, `ProgramData`, `LOCALAPPDATA`, `PATHEXT`, `PSModulePath` and `ProgramFiles*` / `ProgramW6432` (matched without regard to case); `OTEL_LOG_RAW_API_BODIES`, `ENABLE_BETA_TRACING_DETAILED` and `BETA_TRACING_ENDPOINT`; the telemetry switches `CLAUDE_CODE_ENABLE_TELEMETRY`, `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` and `ENABLE_ENHANCED_TELEMETRY_BETA`, the exporter selectors `OTEL_{LOGS,METRICS,TRACES}_EXPORTER`, the content variables `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_ASSISTANT_RESPONSES`, `OTEL_LOG_TOOL_CONTENT` and `OTEL_LOG_TOOL_DETAILS`, every `OTEL_EXPORTER_OTLP_*` variable ending in `_ENDPOINT`, `_HEADERS`, `_PROTOCOL`, `_CERTIFICATE`, `_CLIENT_KEY` or `_INSECURE`, and `OTEL_EXPORTER_PROMETHEUS_HOST` / `_PORT` (ignored there since Claude Code v2.1.282); and `CLAUDE_CODE_PROCESS_WRAPPER`, `CLAUDE_CODE_SYNC_SKILLS`, `CLAUDE_CODE_SYNC_PLUGINS`, `CLAUDE_CODE_PLUGIN_CACHE_DIR` and `CLAUDE_CODE_PLUGIN_SEED_DIR` — are skipped at project scope and emitted only under `--global`. The values that turn telemetry off are the exception and are still written at project scope: `none` for the three exporter selectors, and `0`, `false`, `no` or `off` for `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_CONTENT` and `OTEL_LOG_TOOL_DETAILS` (compared without regard to case). Claude Code versions before v2.1.251 still honored the directory and session-content variables other than `HOME` and `XDG_CONFIG_HOME` from project settings (and versions before v2.1.282 the telemetry group), so on an older Claude Code a project value skipped here would have applied — set it in user settings instead. `CLAUDE_CODE_REMOTE`, `CLAUDE_CODE_ACCOUNT_UUID`, `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_MESSAGING_TOKEN`, `CLAUDE_CODE_PROJECT_DIR_NAME`, `CLAUDE_CODE_RESTRICTED` and the `CLAUDE_CODE_DISABLE_*_RM_*` switches are ignored in every settings file (the messaging socket since Claude Code v2.1.224, the messaging token since v2.1.228 and `CLAUDE_CODE_PROJECT_DIR_NAME` since v2.1.234), so they are skipped in both scopes — set them in the environment Claude Code starts from. Each warning names the skipped variables but never their values. The reference introduces its directory and Windows groups with \"such as\", so a variable it does not name is still written. **Trust caveat:** a permissions file is shareable — `rulesync fetch` copies `.rulesync/permissions.jsonc` out of another repository — so a file whose name promises restrictions must not be able to hand Claude Code a command to run. Rulesync therefore refuses the keys whose value _is_ an executed command, in both scopes and in both directions: `apiKeyHelper`, `awsAuthRefresh`, `awsCredentialExport`, `fileSuggestion`, `gcpAuthRefresh`, `otelHeadersHelper`, `policyHelper`, `processWrapper`, `statusLine` and `subagentStatusLine` are never written (warning once per key) and are silently dropped on import — author commands in [`.rulesync/hooks.jsonc`](#rulesynchooksjsonc), where a reviewer expects them, or set these by hand in `settings.json`. The same line applies inside `sandbox`, which has its own merge branch: `sandbox.ripgrep`, `sandbox.bwrapPath` and `sandbox.socatPath` each name an executable Claude Code runs, so they are refused in both scopes and dropped on import too. The scope caveat reaches inside `sandbox` as well: `sandbox.filesystem.allowManagedReadPathsOnly` and `sandbox.network.allowManagedDomainsOnly` are scoped `Managed`, so they are skipped in both scopes for the same reason the `Managed` top-level keys are — set them in the managed settings file by hand. Keys that widen what Claude Code trusts rather than running something themselves are still written, but every one of them is named in a single summary warning per file — one line listing each setting and what it affects, rather than a run of near-identical lines: `env`, `disableAllHooks`, `disableSkillShellExecution` set to anything but `true` (which re-opens inline shell execution a user setting had turned off), `enableAllProjectMcpServers`, `enabledMcpjsonServers` and `allowedMcpServers`, `autoMode`, `skipAutoPermissionPrompt` and `skipDangerousModePermissionPrompt`, `enabledPlugins` and `extraKnownMarketplaces`, `agent` and `outputStyle` (which replace the prompt and tools every session starts with), `httpHookAllowedEnvVars` and `allowedHttpHookUrls` (which relax what an existing HTTP hook may send and where), `claudeMdExcludes` (which drops the CLAUDE.md files its patterns match), `crossSessionInbound` set to anything but `hold` or `refuse` (which lets messages from your other sessions reach Claude), `modelOverrides` (which decides the inference profile a call is routed to), `skipWebFetchPreflight` set to anything but `false` (which turns off the WebFetch domain safety check), `remoteControlAtStartup` set to anything but `false`, `prUrlTemplate` (which rewrites the PR links Claude Code renders), `companyAnnouncements`, `permissions.additionalDirectories`, a `permissions.defaultMode` of `bypassPermissions`, `acceptEdits` or `auto`, and the `sandbox` paths that loosen the sandbox rather than naming something to run — `enabled`, `autoAllowBashIfSandboxed`, `allowUnsandboxedCommands`, `excludedCommands`, `allowAppleEvents`, `enableWeakerNestedSandbox`, `enableWeakerNetworkIsolation`, `ignoreViolations`, `filesystem.allowRead`, `filesystem.allowWrite`, `network.allowedDomains`, `network.allowMachLookup`, `network.allowUnixSockets`, `network.allowAllUnixSockets`, `network.allowLocalBinding`, `network.httpProxyPort` and `network.socksProxyPort`. The `allow*` lists are in that set because Claude Code merges a list across settings scopes rather than replacing it, so a project file can only ever add to them; their `deny*` counterparts, which restrict, are not. These warnings fire only on the value that actually loosens the policy, so authoring the restrictive one (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays quiet. Each condition names the value that stays quiet rather than the one that warns, so a value of the wrong type — a `skipWebFetchPreflight` of `1` rather than `true` — is reported rather than passed over. `allowUnsandboxedCommands` and `autoAllowBashIfSandboxed` both default to `true`, and an explicit `true` is reported even so: a project `.claude/settings.json` outranks the user file, so writing it there re-opens what a user's `false` closed. `remoteControlAtStartup` is the one key whose _scope_ depends on its value — Claude Code honors a `false` from a project file but ignores a `true`, so a `true` is skipped at project scope and emitted only under `--global`. They are also emitted after the scope filter, so a path the scope drops is reported as skipped rather than as written. Every one of these warnings exists for the same reason: a value that arrived with a fetched override should be visible rather than silent. (`env` is warned about rather than refused because it has too many ordinary uses to drop, even though a value such as `NODE_OPTIONS` or `PATH` does run code.) As with `sandbox`, the filter only applies to what Rulesync writes: a value already in the file is left untouched, and removing a key from the override block does not delete the value an earlier generate wrote — clear it in `settings.json` by hand.\n\nFor OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. OpenCode's `webfetch`, `websearch`, `todowrite`, `question`, and `doom_loop` keys accept only a single action string, so Rulesync emits their canonical `{ \"*\": \"allow\" }` form as `\"allow\"`. If one of these categories contains pattern-specific rules, Rulesync collapses them to the most restrictive action (`deny` > `ask` > `allow`) and logs a warning because OpenCode cannot represent those patterns; a map without `*` includes an implicit `ask` fallback so a narrow allowlist never becomes blanket `allow`, while an empty map becomes `deny` instead of falling through to OpenCode's default allow behavior.\n\nCanonical categories are written under the key OpenCode actually consults: `agent` becomes `task` (and `task` imports back as `agent`), and `write` is folded into `edit`, because OpenCode's write tool asks for the `edit` permission and has no `write` key. When both `write` and `edit` are set, their patterns are merged into one `edit` map, emitted where the canonical `edit` category sits (OpenCode applies the last matching rule across keys as well, so the merged map keeps `edit`'s place relative to keys such as `*`); identical maps are kept as-is, otherwise each pattern keeps the stricter action and the patterns are reordered `allow`, then `ask`, then `deny` (OpenCode's last matching pattern wins, so this order keeps the merged map at least as strict as either input) and a warning is logged. The merge fails closed: an `allow` carve-out under a broader `ask`/`deny` pattern (e.g. `\"*\": \"deny\", \"src/**\": \"allow\"`) stops taking effect once the two maps differ, so give both categories the same map, or set `opencode.permission.edit` (which replaces the merged value) when you need an exact pattern order. Keys under `opencode.permission` are OpenCode's native keys and are emitted as written. An imported `edit` stays `edit`. OpenCode has no notebook tool, so `notebookedit` rules are skipped with a warning.\n\n> **OpenCode-only override (`opencode` key):** OpenCode exposes permission categories that other tools do not understand (e.g. `external_directory`). Placing these in the shared `permission` block would push meaningless entries into Claude Code, Codex, etc. To scope them to OpenCode, add a tool-scoped `opencode` override key alongside the shared block — mirroring the tool-scoped override keys used by [hooks](#hooks) (`opencode.hooks`) and rules frontmatter. Categories under `opencode.permission` are merged on top of the shared block **per category** (the override wins) and are emitted **only** into `opencode.json` / `opencode.jsonc`; every other tool ignores them. Values may use a bare action string (`\"deny\"`) or, for OpenCode keys that support fine-grained matching, a pattern map (`{ \"*\": \"ask\" }`).\n>\n> ```jsonc\n> {\n> \"permission\": {\n> \"bash\": { \"git *\": \"allow\", \"*\": \"ask\" },\n> },\n> // Emitted only into opencode.json's `permission`; never leaks to other tools.\n> \"opencode\": {\n> \"permission\": {\n> \"external_directory\": \"deny\",\n> },\n> },\n> }\n> ```\n>\n> On **import**, any OpenCode category that is not a shared canonical rulesync category (`bash`, `read`, `edit`, `write`, `webfetch`, `websearch`, `grep`, `glob`, `notebookedit`, `agent`, the all-tools key `*`, or an `mcp__*` tool name) is routed into the `opencode` override rather than the shared block, so a subsequent `rulesync generate` does not leak it into other tools.\n>\n> You may also override a **shared** category for OpenCode specifically (e.g. put `webfetch` under `opencode.permission` to give OpenCode a different value than the shared block sends to other tools). On generate this works as expected, but note the override is not round-trip stable for shared categories: re-importing the generated `opencode.json` classifies a shared category back into the shared block, so prefer expressing OpenCode-only categories here and keeping cross-tool categories in the shared block.\n\nFor Hermes Agent, permissions are written into the shared `~/.hermes/config.yaml` (global only). Canonical rules map onto the structures Hermes's runtime actually enforces:\n\n- `bash` `allow` patterns → `command_allowlist`. The allowlist is a list of shell-command patterns, so `allow` rules in other categories are not written there: a `read` or `edit` allow names a path, and an all-tools `*` allow need not name a command either (it is skipped with a warning; write it under `bash` to auto-approve it as a command).\n- `bash` `deny` patterns → `approvals.deny` — Hermes's hard denylist, evaluated **before** `--yolo` / `approvals.mode: off`.\n- `webfetch` `deny` patterns → `security.website_blocklist.domains`.\n- The all-tools `*` category's `deny` and `ask` rules are read too — a rule written there covers shell commands as well — but they are not written into `approvals.deny`, since a pattern under `*` need not be a command. Like every `ask` rule (Hermes has no ask tier), they restrict by **withholding** the `bash` `allow` rules they cover: the canonical order is `deny > ask > allow` whatever the width of the patterns, so `{\"*\": {\"npm *\": \"deny\"}, \"bash\": {\"npm publish\": \"allow\"}}` writes no `npm publish` into the allowlist, and `{\"*\": {\"*\": \"ask\"}}` withholds every `allow`. Each withheld rule is reported with a warning.\n- Every `ask` rule, and `deny` rules in categories other than `bash`/`*`/`webfetch`, have no native per-pattern Hermes primitive; they are skipped with a warning and survive only for round-trip (Rulesync also stores the full canonical config under a private `permissions.rulesync` key so `.rulesync/permissions.jsonc` reconstructs losslessly).\n\n> **Hermes-only override (`hermes` key):** Hermes exposes approval/security controls with no canonical permission category — e.g. `approvals` (`mode`, `cron_mode`, `mcp_reload_confirm`, ...), `security` (`allow_private_urls`, ...), `skills.write_approval`, `memory.write_approval`. Add a tool-scoped `hermes` override key alongside the shared block to author them; its contents are **deep-merged** into `config.yaml` (so an `approvals.mode` here coexists with the `approvals.deny` derived from canonical deny rules) and are emitted **only** for Hermes. The block is a verbatim passthrough, so any current or future Hermes config key can be set without Rulesync modeling each one. Note that the deep merge replaces **arrays** wholesale, so setting `hermes.approvals.deny` or `hermes.security.website_blocklist.domains` overrides (does not append to) the list derived from the shared `permission` block — use it only when you intend to replace the canonical-derived deny list for Hermes. The top-level `permissions` key is reserved by Rulesync for the round-trip blob, so a `permissions` key inside the `hermes` override is ignored.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"rm -rf *\": \"deny\" } },\n> \"hermes\": { \"approvals\": { \"mode\": \"smart\" }, \"security\": { \"allow_private_urls\": false } }\n> }\n> ```\n\nFor Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml` under `[permissions.rulesync]` and sets `default_permissions = \"rulesync\"` (project/global depending on mode). It also generates `.codex/rules/rulesync.rules` from `permission.bash` entries using `prefix_rule(...)`. Current Rulesync-to-Codex mapping supports `bash`, `read`, `edit`/`write`, and `webfetch` categories:\n\n- `bash`: generates one `prefix_rule(...)` per command pattern in `.codex/rules/rulesync.rules` (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`). Codex matches each pattern word as a literal token and the rule as a command prefix, so glob characters are not wildcards there. A single trailing standalone `*` is dropped, because a prefix rule already covers whatever follows: `sudo *` becomes `pattern = [\"sudo\"]`, which also matches a bare `sudo` (as the Letta Code and Tabnine `<prefix> *` translations do). More than one trailing wildcard word (`docker * *`) requires extra arguments a prefix rule cannot express, so it is skipped like the cases below. The legacy `git:*` spelling is read as `git *`. Any other wildcard — inside a word (`npm install*`), in the middle (`git * --no-verify`), `?` or a `[...]` class — has no prefix-rule equivalent, and cutting the pattern before it would widen the rule (a deny on `npm install*` would block every `npm` command), so the pattern is skipped with a warning and Codex does **not** enforce it, even a deny. A pattern of only `*` (or the aliases `* *` and `*:*`) is skipped the same way, since a prefix rule cannot match every command; use `codexcli.approval_policy` for that. The rules file is generate-only: `rulesync import` reads `.codex/config.toml` and does not bring `permission.bash` back from it.\n- `read`: `allow` → `read`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`\n- `edit` / `write`: `allow` → `write`, while `ask`/`deny` → `read` in `permissions.<profile>.filesystem` when the path is exact or ends in `/**`; Codex has no path-level write-approval state, so both non-allow write actions preserve read access without write access and emit a warning. An explicit `read: deny`/`ask` on the **same pattern** takes precedence and emits `deny`. A write-side-only rule (no `read` rule for the same pattern) also emits `deny` when the most specific `read` rule covering the path is a `deny`/`ask` — an exact path `X` or `X/**` covering `X` or anything below it when both are the same kind of path (both absolute, drive, or `~` paths, or both workspace-relative — so a relative `C:` never covers the drive path `C:\\secret\\k`; leading `./` segments are ignored, and for Windows drive paths a backslash counts as a separator and the drive letter is compared case-insensitively, so `c:\\secret` covers `C:\\secret\\k`), `**`, `.`, `./**`, or `:workspace_roots` covering any workspace-relative pattern, or `/**` and `:root` covering every path — because Codex gives the more specific path priority and a `read` or `write` entry would re-open reads the broader rule denies (e.g. `read: { \"/home/me/.ssh/**\": \"deny\" }` + `write: { \"/home/me/.ssh/id_rsa\": \"deny\" }` emits `deny` for both). This applies to a write-side `allow` too: `read: { \"~/.ssh/**\": \"deny\" }` + `edit: { \"~/.ssh/known_hosts\": \"allow\" }` emits `\"~/.ssh/known_hosts\" = \"deny\"` with a warning, since \"writable but not readable\" is not expressible in Codex (add `read: allow` for the same pattern to keep it writable). Only the most specific covering `read` rule counts, and equally specific rules resolve to the restrictive one: `read: { \"/x/**\": \"deny\", \"/x/y\": \"allow\" }` + `edit: { \"/x/y/z\": \"deny\" }` emits `\"/x/y/z\" = \"read\"`, while a more specific `read: allow` that Codex never receives (a non-trailing glob or a Windows device path, skipped as described below) does not count. For absolute, drive, and `~/...` paths, coverage resolves `.`/`..` segments lexically, as Codex does, for comparison only (emitted keys keep their authored form), so `read: { \"/home/me/.ssh/**\": \"deny\" }` covers `/home/me/public/../.ssh/id_rsa`. As an extra safeguard, when the path or the `read: allow` pattern contains such a segment the narrower allow never wins (e.g. `read: { \"/home/me/**\": \"deny\", \"/home/me/public\": \"allow\" }` + `edit: { \"/home/me/public/../.ssh/id_rsa\": \"deny\" }` emits `deny`). Other broader read denials are not matched, so Rulesync warns when it maps a write-side-only `deny` to `read`: a non-trailing glob such as `**/*.pem`, special paths other than `:root` and `:workspace_roots` (e.g. `:tmpdir`), and `~/...` versus the equivalent absolute path (`~/.aws/**` does not cover `/home/me/.aws/credentials`, because Codex expands `~` on the machine it runs on, which Rulesync cannot know); no warning is emitted for an explicit `read: allow` + write-side `deny`, which asks for exactly Codex's `read` level. For other globs, Codex supports only `deny`: Rulesync skips grant-derived `read`/`write` entries with a warning so the baseline applies, and emits `deny` for a write-side `ask`/`deny` restriction rather than generating an invalid profile. Windows device paths (`\\\\?\\...`, `\\\\.\\...`) are not supported for grants: Codex rejects them as workspace-root paths, and outside Windows their `?` is a glob character. When Rulesync runs outside Windows, such a path is not absolute and would be nested under `:workspace_roots`, where Codex rejects it under the Windows path convention and treats it as a meaningless literal name elsewhere, so Rulesync drops every entry for it — including a `deny` — with a warning, and **the path is not protected by the generated profile**; express such a restriction with a plain drive path instead. On Windows, where the path is absolute and emitted at the top level, a grant-derived `read`/`write` entry is skipped with a warning and a write-side `ask`/`deny` restriction emits `deny`. Use a plain drive path such as `C:\\...` instead.\n- A Codex `\"write\"` entry is imported as both `read: allow` and `edit: allow`, since Codex's `write` level includes read access; this keeps a writable path under a broader read deny (e.g. `\":root\" = \"deny\"` + `\":tmpdir\" = \"write\"`) writable on the next generate. Import is one-way for the write-side restriction: a Codex `\"read\"` entry is imported as `read: allow` only. Both `read: allow` + write-side `ask`/`deny` and a write-side-only `edit`/`write` `ask`/`deny` (e.g. `edit: { \"/x/**\": \"deny\" }`, which generates `\"/x/**\" = \"read\"`) therefore lose their write restriction on `rulesync import`, and a later generate hands the path back to the baseline's write access. Rulesync does not re-materialize `\"read\"` as an extra `edit: deny`, because that would push a restriction to every other tool that the Codex profile alone cannot prove was authored; re-add such rules to the canonical permissions after importing.\n- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules); `network.enabled = true` is emitted only when at least one `allow` rule is present. Deny-only domain sets are emitted without `enabled`, which Codex treats as restricted (its default) while the deny entries still round-trip back into Rulesync rules. Codex rejects the global wildcard `*` in denied domains at config load time, so `webfetch: { \"*\": \"deny\" }` is skipped with a warning (unlisted domains are denied by Codex's allowlist-first policy anyway); `webfetch: { \"*\": \"allow\" }` is emitted as a regular `\"*\" = \"allow\"` domain entry, which Codex accepts for denylist-only setups ([openai/codex#15549](https://github.com/openai/codex/pull/15549)). On import, `deny` entries are always taken, while `allow` entries are imported only when `enabled = true` is explicit — Codex treats a missing `enabled` as restricted, so importing an allow entry from a disabled profile would activate a grant Codex never had. A Codex profile with `network.enabled = true` but no `domains` is imported as `webfetch: { \"*\": \"allow\" }`, which reflects Codex's default semantics where `enabled = true` grants sandbox-wide network access (under Codex's experimental `network_proxy` feature, `enabled = true` without an allowlist blocks requests instead, and the regenerated `\"*\" = \"allow\"` entry is the closest equivalent).\n\nRelative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.\":workspace_roots\"` instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, `~/...`, or named roots. Leading `./` segments are stripped from these table keys before the `read`/`edit`/`write` rules are merged, because Codex rejects a `:workspace_roots` subpath whose first segment is `.` (only the exact `.` key, the workspace root itself, is special-cased): `./` and `./**` become `.` (not the bare glob `**`, which Codex accepts only with `deny`), and `./src/**` becomes `src/**`. Rules that end up on the same key are merged as if they had been written with it (e.g. `read: { \"src/**\": \"allow\" }` + `edit: { \"./src/**\": \"allow\" }` emits `\"src/**\" = \"write\"`), and within one category the more restrictive action wins (e.g. `./src` and `src`). A workspace-relative pattern Codex would reject — one with a `..` segment (e.g. `src/../secret`, `../out/**`) or an empty leading segment (e.g. `.//src`, which is never turned into the absolute `/src`) — would make Codex refuse the whole config, so Rulesync drops it with a warning; like a Windows device path, **the dropped rule, even a `deny`, does not protect the path**. Absolute, drive, `~/...`, and special-path keys are never rewritten. Codex supports `read`/`write` only for exact paths or trailing `/**` subtree rules; Rulesync skips other grant-derived globs with a warning so the baseline applies, while write-side restrictions fall back to `deny`. Rulesync also sets `glob_scan_max_depth = 8` when generated workspace-root rules contain unbounded `**` patterns. A direct `\":workspace_roots\"` rule (e.g. `read: { \":workspace_roots\": \"deny\" }`) is kept as the table's `\".\" = <access>` entry — the key Codex uses for the workspace root itself — when workspace-relative rules turn `:workspace_roots` into a table, so the workspace does not fall back to the baseline's access (a warning is still logged; if an explicit `.` or `./` rule also exists, the more restrictive access wins). On import, a `\".\"` table entry is imported as the portable `.` pattern rather than the Codex-only `:workspace_roots`, so a canonical `read: { \".\": \"deny\" }` round-trips unchanged.\n\nThe `:workspace_roots` table also receives a default `.git` carve-out: `\".git/**\" = \"write\"`. Codex's `:workspace` baseline keeps `.git` read-only inside workspace roots, which denies basic git workflows (commit/stage operations write to `.git/index`, `.git/objects`, refs, and logs; everyday commands such as `git remote add`, `git push -u`, and local-scope `git config` write to `.git/config`). The write rule reopens the whole subtree, including `.git/config` — an earlier `\".git/config\" = \"read\"` security guard (a writable `.git/config` lets a sandboxed process set keys like `core.fsmonitor` or `core.hooksPath` that execute code outside the sandbox) was dropped because it blocked those everyday commands while the protection it added was already partial (`.git/hooks/`, and `.git/modules/**` for submodules, remains writable so hook managers such as lefthook and simple-git-hooks keep working; a sandboxed process could still install a hook directly). Users who want stricter isolation can author a more specific rule (e.g. `read: { \".git/config\": \"allow\" }` or `read: { \".git/hooks/**\": \"allow\" }`) in the canonical permissions, which wins over the default (Codex resolves the more specific path with priority). Because `.git/**` is an unbounded `**` pattern, the carve-out also means `glob_scan_max_depth = 8` is effectively always emitted unless it is suppressed.\n\nThe carve-out is skipped in these cases: a user rule for the same pattern always wins per key; the `codexcli.git_write_rules` override set to `false` suppresses it entirely (only an explicit `false` does; the default is `true`); and it is not injected when `codexcli.base_permission_profile` is `\":read-only\"` (it would grant `.git` write access inside a sandbox the user explicitly chose to keep read-only) or when the canonical rules contain a direct `\":workspace_roots\"` pattern (a whole-tree access decision that the defaults must not override). It is also skipped, with a warning, when the most specific other entry of the generated `:workspace_roots` table that covers `.git` is `\"deny\"` or `\"read\"` — for example `\"**\" = \"deny\"` from `read: { \"**\": \"deny\" }`, `\".\" = \"deny\"` from `read: { \".\": \"deny\" }`, or `\".\" = \"read\"` from `edit: { \".\": \"deny\" }` — because Codex resolves the more specific `.git/**` entry with priority, so the carve-out would otherwise reopen `.git` under that restriction. A narrower rule only keeps the carve-out when it grants `.git` write access: `read: { \"**\": \"deny\", \".git\": \"allow\" }` emits `\".git\" = \"read\"` and still skips it, while adding `edit: { \".git\": \"allow\" }` (`\".git\" = \"write\"`) keeps it. `:root` and `/**` denies do not suppress it, because they are not table entries and the `:workspace` baseline re-grants the workspace above them; to keep `.git` writable anyway, author `read` and `edit` allows for `.git/**` (both are needed, since a write-side allow alone under a broader read deny generates `\"deny\"`), or set `codexcli.git_write_rules: false` to silence the warning when the restriction is intended. Coverage ignores non-trailing globs such as `**/.git/**`. Like `:minimal`, the default-valued carve-out is not imported into the Rulesync model on `rulesync import` — it is re-added on every generate — while customized `.git` values import normally. Because the skip decision reads only the emitted table, import applies the same test: a `\".git/**\" = \"write\"` entry in a table that would suppress the default is imported as a user-authored rule, so the result is stable across generate → import → generate. One limitation: the `git_write_rules` flag itself cannot be recovered from `config.toml`, so it does not round-trip through `rulesync import`; if you opted out with `false`, re-add the flag to the canonical permissions config after importing (and if you want the same `.git` rules while opted out, author them as canonical `read`/`write` rules rather than hand-writing them in `config.toml` — though note that import cannot tell a user-authored `\".git/**\" = \"write\"` from the default carve-out, so unless the rest of the table restricts `.git` as described above, that exact pattern/value pair is still skipped on import and must be re-authored in the canonical config afterwards). Migration note: configs generated before the `\".git/config\" = \"read\"` default was removed still carry that entry, and `rulesync import` now treats it as a user-authored rule — it lands in the canonical config as `read: { \".git/config\": \"allow\" }` and, because Codex gives the more specific path priority, keeps `.git/config` read-only on every regenerate. If you want the current writable default instead, delete that rule from the canonical permissions after importing. Likewise, configs generated before the carve-out became conditional carry `\".git/**\" = \"write\"` even in tables that restrict `.git` (e.g. next to `\"**\" = \"deny\"`); `rulesync import` treats that entry as user-authored and adds `read`/`edit` allows for `.git/**` to the canonical config, which other tools then receive too. Delete those allows after importing unless you want `.git` writable.\n\nThe generated `[permissions.rulesync]` profile always extends one of Codex's built-in permission profiles via `extends`. The baseline is chosen with the `codexcli.base_permission_profile` override key (`\":read-only\"` | `\":workspace\"` | `\":danger-full-access\"`) and defaults to `\":workspace\"` when unspecified. Codex's built-in `:workspace` baseline grants read access to the whole filesystem and write access to the entire workspace root plus `/tmp` and `$TMPDIR` (with carve-outs protecting `.git`, `.codex`, and `.agents`), while `:read-only` keeps command execution read-only; the generated `filesystem` entries then grant or deny access on top of the chosen baseline. Codex's third built-in profile, `:danger-full-access`, is rejected by `extends` at Codex config load time — so selecting it works differently: Rulesync emits `default_permissions = \":danger-full-access\"` directly and skips the managed `[permissions.rulesync]` profile entirely (with the sandbox removed there is nothing for filesystem/network rules to refine; canonical `read`/`edit`/`write`/`webfetch` rules are ignored for Codex CLI with a warning, and any stale managed profile from a previous generate is pruned while sibling hand-written profiles are preserved). On import, a profile's `extends` value round-trips back into `codexcli.base_permission_profile` when it names one of the two extendable built-ins, and a top-level `default_permissions = \":danger-full-access\"` round-trips the same way; a custom parent profile is skipped and replaced by the managed baseline on regeneration (with a warning).\n\nRulesync emits `\":minimal\" = \"read\"` in the generated filesystem table by default. This enables `include_platform_defaults()` ([FileSystemSpecialPath::Minimal](https://github.com/openai/codex/pull/13434)), which provides the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows. `:minimal` is the only special path treated as a fixed baseline: it is always present in the generated table and is never imported into Rulesync's own permission model, regardless of its value. A canonical rule for `:minimal` still overrides the emitted value on generate (e.g. a `write: { \":minimal\": \"allow\" }` rule emits `\":minimal\" = \"write\"` — see the [FAQ](../faq.md#codex-cli-denies-ssh-agent-access-temp-dir-writes-or-reading-its-own-config-with-a-generated-permissions-profile) for when that is needed), but because import always skips `:minimal`, such a customization does not round-trip: after `rulesync import`, re-author the rule or the next generate falls back to `\"read\"`. The other special paths `:root`, `:tmpdir`, and `:slash_tmp` are user-managed access rules that are imported into the Rulesync model and re-emitted from it like any ordinary filesystem entry (`:root = \"deny\"` becomes a read/edit deny, `:tmpdir = \"write\"` becomes an edit allow, and so on). Because they round-trip through `.rulesync/permissions.jsonc` rather than relying on an existing `.codex/config.toml`, a restrictive value such as `:root = \"deny\"` survives a fresh-clone `rulesync generate` with no pre-existing Codex config.\n\n`network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration — as it does any other network key it does not model (e.g. `dangerously_allow_all_unix_sockets` or Codex's proxy keys), since network settings are user territory by design. `network.enabled` is only half-managed: Rulesync sets `enabled = true` itself when the canonical model contains an allow domain, but when a regeneration computes no `enabled` value, a user-authored `enabled` is preserved (with a warning) instead of being deleted — see the [FAQ](../faq.md#codex-cli-denies-ssh-agent-access-temp-dir-writes-or-reading-its-own-config-with-a-generated-permissions-profile) for the recommended user-managed entries. The preservation applies only when the existing profile carries no allow domain: an existing `enabled` next to allow domains is Rulesync's own managed output, so removing every webfetch allow rule from the canonical model removes `enabled` too (falling back to Codex's restricted default) instead of leaving an unscoped `enabled = true` behind. Note that `filesystem`, `network.domains`, and `extends` are always managed by Rulesync (`filesystem`/`network.domains` derived from `edit`/`write`/`webfetch` rules, `extends` from `codexcli.base_permission_profile`), so hand-authored values in those fields will be replaced on regeneration.\n\n> **Codex CLI-only override (`codexcli` key):** Codex CLI's permission surface is richer than the canonical allow/ask/deny model — its approval workflow, permission-profile baseline, and per-app tool gating have no canonical category. Add a tool-scoped `codexcli` override to author them: except for `base_permission_profile`, its fields are written verbatim as **top-level `.codex/config.toml` keys** (the override wins per key; existing sibling keys the user set directly are preserved, and table values are shallow-merged) while the shared `permission` block keeps driving the managed `[permissions.rulesync]` profile and `default_permissions`. Supported keys: `base_permission_profile` (`:read-only` | `:workspace` | `:danger-full-access`, default `:workspace` — not a top-level key; it becomes the managed profile's `extends` baseline, or with `:danger-full-access` the directly-selected `default_permissions` value, see above), `approval_policy` (`on-request` | `never`, or a `{ granular = { … } }` table kept verbatim; defaults to `on-request` when neither the override nor the existing config sets it — `on-failure` is a deprecated alias Codex still reads as `on-request` and is written with a warning; `untrusted` was [retired in Codex 0.149.0](https://github.com/openai/codex/pull/39630) and makes Codex refuse to start, so an authored `untrusted` is skipped with a warning (the key then falls back to the existing value or the default), an `untrusted` left behind in `config.toml` by an earlier Rulesync is replaced with the default on generate rather than preserved, and an existing `untrusted` is not imported — each with a pointer to the replacement — a user-level `[projects.\"<path>\"] trust_level = \"untrusted\"` entry, which is per-machine state Rulesync does not author), `apps` (per-app tool gating — `apps.<id>.tools.<tool>.approval_mode` / `.enabled`, `apps.<id>.default_tools_approval_mode`), `approvals_reviewer` (`user` | `auto_review` (legacy alias `guardian_subagent`), or a table; defaults to `auto_review` when neither the override nor the existing config sets it), `tui` (the `[tui]` table — e.g. `vim_mode_default = true` for the modal Vim composer added in Codex 0.129.0, and the `tui.keymap.*` bindings beside it; the shallow merge applies one level deep, so authoring `keymap` replaces the whole existing `[tui.keymap]` table rather than merging into it), and `git_write_rules` (boolean, default `true` — like `base_permission_profile` it is not a top-level key: it controls whether the managed profile's `:workspace_roots` table emits the default `.git` carve-out described above; only an explicit `false` suppresses it). **Deprecated:** `sandbox_mode` (`read-only` | `workspace-write` | `danger-full-access`) with the sibling `sandbox_workspace_write` table (`network_access`, `writable_roots`, …) belong to Codex's classic sandbox system, which permission profiles supersede — Codex prioritizes these legacy keys over permission profiles when both are present, so authoring them disables the generated `[permissions.rulesync]` profile; they are still accepted (with a warning) so existing configs round-trip, but use `base_permission_profile` and the shared `permission` block instead. On import, the top-level keys round-trip back into the `codexcli` override, and the managed profile's `extends` round-trips into `base_permission_profile`. It is a `looseObject`, so future top-level Codex config keys can be authored here (merged verbatim on generate; only the listed keys are re-extracted on import). Example: `{ \"permission\": { … }, \"codexcli\": { \"base_permission_profile\": \":workspace\", \"approval_policy\": \"on-request\", \"approvals_reviewer\": \"auto_review\" } }`. **Out of scope:** `mcp_servers.*` per-MCP gating is **not** authorable here — it is owned by the MCP feature (`codexcli-mcp.ts` writes the `mcp_servers` tables in the same `config.toml`), and `permissions` / `default_permissions` are owned by the canonical model; any such key placed in the override is skipped with a warning. See the [Codex configuration reference](https://developers.openai.com/codex/config-reference) and [permissions docs](https://developers.openai.com/codex/permissions).\n\nFor Kiro, this generates tool permission settings in `.kiro/agents/default.json` (project mode):\n\n- `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands`\n- `read` maps to `toolsSettings.read.allowedPaths` / `toolsSettings.read.deniedPaths`\n- `edit` / `write` map to `toolsSettings.write.allowedPaths` / `toolsSettings.write.deniedPaths`\n- `grep` maps to `toolsSettings.grep.allowedPaths` / `toolsSettings.grep.deniedPaths`\n- `glob` maps to `toolsSettings.glob.allowedPaths` / `toolsSettings.glob.deniedPaths` (both emitted only when a rule is present, so existing configs do not gain empty tables)\n- `webfetch` / `websearch` with pattern `*` map to `allowedTools` entries (`web_fetch` / `web_search`)\n- `ask` rules are skipped with a warning (Kiro config does not support explicit ask entries)\n\n> **Kiro-only override (`kiro` key):** Kiro's agent config exposes per-tool `toolsSettings` knobs with no canonical allow/ask/deny category. Author them through a tool-scoped `kiro` override under `toolsSettings`: the shell auto-trust flags `shell.autoAllowReadonly` / `shell.denyByDefault`, the `aws` built-in tool's `allowedServices` / `deniedServices` (+ `autoAllowReadonly`), and the `web_fetch` domain trust arrays `trusted` / `blocked` (regex host patterns; Kiro documents these for `web_fetch` only — `web_search` has no domain-trust surface). Example: `{ \"permission\": { … }, \"kiro\": { \"toolsSettings\": { \"shell\": { \"autoAllowReadonly\": true }, \"aws\": { \"allowedServices\": [\"s3\"], \"deniedServices\": [\"eks\"] }, \"web_fetch\": { \"trusted\": [\".*github\\\\.com.*\"] } } } }`. The override is **deep-merged per `toolsSettings` key** (the override wins at the leaf) so authoring `shell.autoAllowReadonly` keeps the canonical-generated `shell.allowedCommands`; the shared `permission` block keeps driving `shell.{allowed,denied}Commands`, `read`/`write`/`grep`/`glob` paths, and the `web_fetch`/`web_search` `allowedTools` toggles. Existing non-canonical `shell` flags are preserved across regenerate even without an override. On **import**, these Kiro-specific surfaces are lifted into the `kiro` override so they round-trip. It is a `looseObject` at every level, so future Kiro `toolsSettings` fields pass through verbatim. Kiro MCP `disabledTools` lives in the separate `.kiro/settings/mcp.json` file and is modeled by the MCP feature; MCP `autoApprove` remains outside this permissions translator. See the [Kiro built-in tools](https://kiro.dev/docs/cli/reference/built-in-tools/) and [configuration reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference/) docs.\n\nFor Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (project mode) or `~/.cursor/cli-config.json` (global mode). Cursor CLI only supports `allow` and `deny` decisions, so `ask` rules are skipped with a warning. Tool categories are mapped to PascalCase Cursor tool names (`bash` → `Shell`, `read` → `Read`, `edit`/`write` → `Write`, `webfetch` → `WebFetch`, `mcp__*` → `Mcp`). Existing Cursor-specific entries that Rulesync does not manage (for example, MCP entries with extra fields) are preserved on round-trip. Note Cursor scopes the file asymmetrically — \"Only permissions can be configured at the project level. All other CLI settings must be set globally\" — so in project mode Rulesync contributes only the `permissions` key, and no longer stamps `version` or `editor.vimMode` there (both are written in global mode, where Cursor reads them). Content already in a project `cli.json` is passed through untouched either way, including a `version` an earlier Rulesync version stamped: Rulesync cannot tell a key it wrote from one you wrote, so it does not delete it.\n\n> **Cursor-only override (`cursor` key):** Cursor's `cli.json` carries scalar autonomy settings with no canonical permission category — `approvalMode` (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object (`mode`/`networkAccess`). Add a tool-scoped `cursor` override to author them: its fields are merged into the top level of the config file while the shared `permission` block keeps driving the `permissions.allow`/`permissions.deny` arrays (the override cannot clobber that managed block). These settings are **global-only** upstream, so they are written only when generating with `--global`; in project scope they are skipped with a warning naming each one, rather than written into a `.cursor/cli.json` where Cursor would ignore them and the authored setting would silently never take effect. On import, `approvalMode` and `sandbox` round-trip back into the `cursor` override. It is a `looseObject`, so `sandbox`'s (currently undocumented) value set passes through verbatim and extra `cli.json` keys can be authored here (they are merged verbatim on generate); note that only `approvalMode` and `sandbox` are re-extracted on import.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"cursor\": { \"approvalMode\": \"auto-review\" }\n> }\n> ```\n>\n> The separate Cursor **IDE** `permissions.json` (`mcpAllowlist`, `terminalAllowlist`, `autoRun.*`) is a different file and is not targeted by this translator.\n\nFor GitHub Copilot (`copilot`), this manages the three `chat.tools.*.autoApprove` maps and the `chat.permissions.default` setting in the workspace `.vscode/settings.json` (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: `bash` → `chat.tools.terminal.autoApprove` (command patterns), `edit` → `chat.tools.edits.autoApprove` (file globs) and `webfetch` → `chat.tools.urls.autoApprove` (URL patterns). In all three, `allow` → `true` (auto-approve) and `deny` → `false` (never auto-approve); an `ask` rule is represented by **omitting** the entry, so VS Code falls through to its default in-chat approval prompt. The canonical `read` category has no VS Code approval surface, and `write` is deliberately **not** folded into the edits map alongside `edit` — doing so would make the two indistinguishable on import — so neither is emitted. VS Code also accepts a `{ \"approveRequest\": …, \"approveResponse\": … }` object per URL pattern; that form has no canonical equivalent, so it is skipped on import, and because Rulesync owns the key outright it is replaced whenever the canonical config carries any `webfetch` rule. `.vscode/settings.json` is a general workspace file (JSONC), so Rulesync merges only those keys non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The optional `copilot.defaultPermissionMode` override writes VS Code's `chat.permissions.default` setting — the permission level new chat sessions start with: `default` (Default Permissions), `autoApprove` (Bypass Approvals) or `autopilot` (Autopilot). Without the override the key is left as it is in the file, and on import a recognized value is read back into the override. The all-or-nothing `chat.tools.global.autoApprove` boolean and the registry-allowlist `chat.mcp.access` setting are intentionally **not** mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the [VS Code agent approvals docs](https://code.visualstudio.com/docs/agents/approvals) and the [edit-approval docs](https://code.visualstudio.com/docs/copilot/chat/review-code-edits).\n\nFor Zoo Code (`zoocode`), this manages the two command lists `zoo-code.allowedCommands` and `zoo-code.deniedCommands` in the workspace `.vscode/settings.json` (project mode only). Zoo Code is a VS Code extension and has no policy file in its `.roo/` tree; these two settings are contributed without a `scope`, which in VS Code means they are settable per workspace, and `ClineProvider.mergeCommandLists()` unions the workspace values into the lists the auto-approval decision reads. Only the canonical `bash` category maps, since Zoo Code gates terminal commands and nothing else through these settings: `allow` → `allowedCommands`, `deny` → `deniedCommands`, and an `ask` rule is represented by **omitting** the pattern from both lists so Zoo Code falls through to its own approval prompt. Entries are matched as command **prefixes**, and Zoo Code resolves a command matching both lists by the **longer** prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists imports as `deny`. Patterns are literal prefixes rather than globs: apart from a bare `*` (the one entry treated as a wildcard), a pattern such as `rm -rf *` is compared with `startsWith` and so never matches `rm -rf /`. Rulesync handles that mismatch by which way it fails. A glob- or regex-shaped **deny** fails open — the entry never matches, so the command stays auto-approved — so the literal prefix it pins down is **added alongside** it (`rm -rf *` also emits `rm -rf `, `/^curl /` also emits `curl `), which makes the deny take effect and denies at least everything the original named; the addition is reported at generate time so you can narrow it if it is wider than you meant. Your own pattern stays in the file, so importing the settings back does not quietly narrow the canonical rule and change what other targets generate from it. The added prefix comes back with it, though: importing from these targets leaves the derived entry in the canonical config alongside the pattern you wrote, and every other target then emits it too (`Bash(rm -rf *)` gains a `Bash(rm -rf )` beside it). It denies no more than the pattern it came from, so it is redundant rather than wrong — delete it from `.rulesync/permissions.jsonc` if you would rather not carry it. A deny that pins down no prefix at all is left as-is and reported as inert instead: `*.sh` would yield the empty prefix, which `startsWith` matches every command against, and an alternation such as `npm run (build|test)` names alternatives rather than a prefix — write one entry per alternative. A glob-shaped **allow** fails closed — it approves fewer commands than it looks like it does, and the rest reach the approval prompt — so it is passed through unchanged and only warned about. Only unambiguous matcher syntax counts here: `*`, grouping and alternation, a leading `^`, and **anchored** `/^…/` regex literals. Everything else is a literal prefix and is left strictly alone. That includes absolute command paths — `/bin/sh` and `/etc/init.d/x` have the shape of a regex literal but are commands, which is why the anchor is required — and shell syntax generally, since Zoo Code restores variables, brackets, braces and quoted strings verbatim before matching: `echo $HOME`, `[ -f x ]` and `mv a{,.bak}` really do match, and widening them would auto-deny far more than you wrote. Once the `bash` category is stated, Rulesync owns both keys, and the two empty cases differ because their contributed defaults do. An empty allow list is written as `[]`: Zoo Code reads the _effective_ setting value, and `allowedCommands` is contributed with the default `[\"git log\", \"git diff\", \"git show\"]`, so removing the key would silently re-grant those three auto-approvals. An empty deny list **retracts** its key instead: `deniedCommands` is contributed as `[]`, so nothing resurfaces, and because VS Code resolves array settings by scope precedence rather than by merging, writing `[]` per workspace would erase a deny list you hand-authored in your user-scope `settings.json`. A canonical config that states no `bash` category at all leaves both keys exactly as you wrote them. `.vscode/settings.json` is a general workspace file (JSONC) shared with the `copilot` target's `chat.tools.*.autoApprove` keys, so Rulesync merges only its own keys non-destructively and never deletes the file. VS Code's user-scope `settings.json` lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The `zoo-code.*` namespace is Zoo-era (the v3.74.0 rebrand renamed it from `roo-cline.*`), so the `roo` target deliberately does not emit these keys and writes its own lineage's spelling instead — see the Roo Code paragraph below. See the [Zoo Code settings contributions](https://github.com/Zoo-Code-Org/Zoo-Code/blob/main/src/package.json).\n\nFor Roo Code (`roo`), this manages the same pair of command lists under the archived lineage's own spelling, `roo-cline.allowedCommands` and `roo-cline.deniedCommands`, in the workspace `.vscode/settings.json` (project mode only). The extension's package name is `roo-cline`, so that is the namespace its contributed settings live under; both keys are present in Roo Code's final release, v3.54.0, which is why they are in the `roo` target's scope rather than being a post-fork Zoo Code addition. The mapping is identical to Zoo Code's, and shares its implementation: only the canonical `bash` category maps, `allow` → `allowedCommands`, `deny` → `deniedCommands`, an `ask` rule is represented by omitting the pattern from both lists, once the category is stated an empty allow list is written as `[]` so the contributed `allowedCommands` default cannot resurface while an empty deny list retracts its key so a user-scope deny list survives, glob- or regex-shaped denies additionally emit the literal prefix they pin down (and are reported) while glob-shaped allows are passed through with a warning, and a canonical config stating no `bash` category leaves both keys untouched. Roo Code resolves a command matching both lists by the **longer** prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists still imports as `deny`. Because `roo` and `zoocode` write different keys in the same file, enabling both targets leaves all four keys in `.vscode/settings.json` and neither adapter touches the other's pair; the general advice to pick one of the two targets per project still applies, since every other feature they share writes the same files. See the [Roo Code v3.54.0 settings contributions](https://github.com/RooCodeInc/Roo-Code/blob/v3.54.0/src/package.json).\n\nFor IBM Bob (`bob`), permissions are **global-only** and manage the command lists of the `approval` block in `~/.bob/settings/settings.json`, the user settings file Bob IDE and Bob Shell share. Bob documents that block for the user file only; whether a workspace `.bob/settings.json` honors it is undocumented, so `--global` is required. Only the canonical `bash` category maps, onto the `execute_command` entry of `approval.allowedExecutors`: `allow` → `approvedCommands` and `deny` → `deniedCommands`. Bob matches each entry as a **prefix** of the full command line, and `deniedCommands` always takes precedence over `approvedCommands`. `ask` has no list of its own — a command in neither list keeps Bob's approval prompt — so an `ask` rule writes nothing and withholds every allow rule it overlaps, compared at prefix width: `{ \"git \": \"allow\", \"git push *\": \"ask\" }` writes no `git ` entry, because that prefix would auto-approve `git push`. The restricting rules of the all-tools `*` category are folded in the same way they are for Factory Droid. Because Bob compares literal text, a glob- or regex-shaped `deny` such as `rm -rf *` is written together with the literal prefix it pins down (`rm -rf `) so the deny takes effect, a glob-shaped `allow` is left as written (it approves fewer commands than it looks like), and Bob documents no bare `\"*\"` wildcard, so a `\"*\"` entry is reported as one that never matches. Such a deny — `\"*\"`, `*.sh`, or an alternation that pins down no prefix — fails closed: since it can never block anything, every `allow` entry is withheld (and named in a warning) so the commands it was meant to block reach Bob's approval prompt instead of being auto-approved. Once `bash` is stated, rulesync owns both lists: `approvedCommands` is always written (`[]` when nothing is allowed) and an empty deny list removes `deniedCommands`. When `bash` is not stated, the command lists are left untouched. Other canonical categories (`read`, `edit`, `webfetch`, ...) have no per-pattern counterpart — Bob can only switch a whole tool group on — so their rules are skipped with a warning; rulesync does not infer group switches from them. Bob's whole-group switches are authored through the Bob-only `bob` override block instead, and each `approval` key the block names replaces the one in the settings file (`autoApprove` is merged toggle by toggle instead): `bob.approval.allowed_permissions` (the groups Bob auto-approves: `read`, `edit`, `execute`, `mcp`, `skill`, `todo`, `subtask`, `subagent`, `mode`; a group ID outside that list is written as authored with a warning, so a group Bob adds later still works), `bob.approval.permissionOptions` (per-group options such as `{ \"groupId\": \"read\", \"enableOutsideWorkspace\": true }`; Bob documents `enableOutsideWorkspace` for the `read` group only, so any other group gets a warning), and `bob.autoApprove` (the top-level boolean `autoApprove` toggles such as `{ \"skills\": true }`, merged over the existing object; a toggle other than `skills` is written with a warning), for example `{ \"permission\": {}, \"bob\": { \"approval\": { \"allowed_permissions\": [\"read\", \"todo\"] }, \"autoApprove\": { \"skills\": true } } }`. Because the file is Bob's user settings, a switch the override newly turns on applies to every project on the machine, so it is named in a warning — check it if the permissions file arrived with a repository you cloned. Bob's docs show `execute` in `allowed_permissions` next to the command lists but do not say whether the lists apply without it, so check that group yourself. When neither `bash` nor the `bob` override is stated, no settings file is created. Everything rulesync does not author is preserved: a group switch the override leaves out, executor entries for other tools, other fields of the `execute_command` entry, the `hooks` key written by the hooks feature, and every other setting. On import, `approvedCommands` and `deniedCommands` become `bash` `allow` and `deny` rules (`deny` wins for a pattern in both lists), and `allowed_permissions`, `permissionOptions` and `autoApprove` are lifted into the `bob` override block — from then on rulesync manages those keys, so a change made later in Bob's settings UI is overwritten by the next generate unless you import again. A key holding an entry the override cannot carry (a non-string group ID, an option field other than `groupId`/`enableOutsideWorkspace`, a non-boolean toggle) is not lifted, so a generate leaves it as is. The settings file is never deleted. See the [Bob approval settings docs](https://bob.ibm.com/docs/shell/configuration/approval-settings) and the [Bob Shell skills docs](https://bob.ibm.com/docs/shell/features/skills) for `autoApprove.skills`.\n\nFor Codewhale (`codewhale`), global permissions (`--global`) manage the `[[rules]]` records of `~/.codewhale/permissions.toml`, the sibling of the user `config.toml` and the only place Codewhale reads permission rules (the project `.codewhale/config.toml` overlay can only tighten approval, sandbox and shell settings; see the project paragraph below for `allow_shell`); project permissions manage the repo constitution described at the end of this section. The legacy `~/.deepseek/` location and a `CODEWHALE_HOME` / `CODEWHALE_CONFIG_PATH` override are not followed; a warning is printed when either variable is set. Codewhale consults typed rules only for shell commands and its file tools, so `bash` maps to `exec_shell`, `read` to `read_file`, `write` to `write_file`, `edit` to both `edit_file` and `apply_patch` (a `write` `deny` / `ask` is written for `apply_patch` too, since a patch can create files), `grep` to `grep_files`, `glob` to `file_search`, and `list` to `list_dir`; other categories (`webfetch`, `websearch`, `mcp`, ...) are skipped with a warning, because a rule for any other tool is never evaluated. The `deny` / `ask` rules of the all-tools `*` category are written for shell commands and for every file tool (a glob one Codewhale cannot match as a path fails closed for the file tools, as below); its `allow` rules are skipped. Codewhale expands no wildcards (`*`, `?` or a `[...]` class). A `bash` pattern of `*` or `**` becomes a tool-wide rule, `git *` becomes the command prefix `git` (matched case-insensitively on word boundaries), and a wildcard-free `allow` becomes an exact command (`command_exact = true`), while a wildcard-free `deny` or `ask` becomes a prefix — the way Codewhale's own deny rules match — so it also covers the command with more arguments. A restricting pattern with a wildcard elsewhere is broadened, with a warning, to the literal prefix it pins down (`git push* --force` → `git`); an `allow` of that shape, or a pattern that pins down no prefix, is skipped. A path pattern of `*` or `**` becomes a tool-wide rule and a plain path becomes an exact `path`; any other glob is skipped, since Codewhale compares paths exactly. A skipped `deny` / `ask` fails closed: every `allow` rule it overlaps (any one command or path matching both) for a tool it was meant to restrict is withheld and named in a warning — the preserved `workspace` grants described below included — so `{ \"*\": \"allow\", \"secrets/**\": \"deny\" }` under `read` writes no `read_file` allow and those reads keep Codewhale's approval prompt, while an allowed `src/a.ts` beside it is still written. Commands are compared case-insensitively; paths are compared case-insensitively with empty and `.` segments dropped (as Codewhale normalizes them) and a `**` segment also matching no directory; an allowed directory for `grep_files`, `file_search` or `list_dir` (or the workspace root `.` for any file tool) covers everything under it; and an absolute, home-relative or `..` path is treated as overlapping, since it cannot be compared with a workspace-relative pattern. Within the file Codewhale decides by action before specificity — `deny` beats `ask` beats `allow` whatever the order — so `{ \"git *\": \"ask\", \"git status\": \"allow\" }` still asks for `git status`. A typed `ask` only prompts in an approval posture that can prompt: under Full Access or auto-approval it does not, and with `approval_policy = \"never\"` it blocks; a `deny` always blocks. Rules rulesync cannot express (a `workspace`-scoped record, an exact `deny`, a tool other than those above, ...) are authored under a tool-scoped `codewhale.rules` array of native records, written ahead of the converted rules as authored except that a missing `action` is written out as `ask`, its Codewhale default. rulesync owns the file's rules with one exception: records carrying a `workspace` — the repository-scoped grants Codewhale's approval card remembers — are kept across a regenerate (remove one with Codewhale's `/permissions remove`, and, once imported, from `codewhale.rules` as well), while an existing record Codewhale would reject is dropped with a warning. The plain prefix and path `ask` rules the approval card also appends cannot be told apart from rulesync's output and are replaced, so import first (or remove them with `/permissions remove`) to keep them. A file that fails to parse is an error. On import, records that round-trip through the mapping above become canonical rules (a prefix as `<prefix> *`, an exact `allow` as the literal command, and an `edit_file` rule — or a `write_file` restriction — only when a matching `apply_patch` rule exists), and the rest — including the weaker of two rules for the same pattern — go to `codewhale.rules`. The file is never deleted and is not created when nothing maps. See the [Codewhale configuration docs](https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md) and the [authorization order docs](https://github.com/Hmbown/Codewhale/blob/main/docs/AUTHORIZATION_ORDER.md).\n\nIn project scope, Codewhale permissions become the enforced `protected_invariants` of the repo constitution `.codewhale/constitution.json`: an invariant object with `paths` that Codewhale compiles into a write hold for `write_file`, `edit_file`, `apply_patch` and `fim_edit`. Each `deny` / `ask` pattern of the `write`, `edit` and all-tools `*` categories becomes one invariant `{ \"text\": \"rulesync permissions: writes to <glob> are denied\", \"paths\": [\"<glob>\"], \"action\": \"block\", \"managed_by\": \"rulesync\" }` (`ask` keeps `action: \"ask\"` and the text `... need approval`), with the strongest action per glob. Because the hold covers every write tool, a `write` restriction also holds edits and an `edit` restriction also holds new files. Repo law has no allow shape, so `allow` rules are skipped, as are the other categories (`read`, `bash`, `webfetch`, ...), with a warning (a `bash` deny on every command is the exception described below); use `--global` for those. Codewhale matches globs (with `globset`, where `*` and `?` also cross `/`, and matching is case-sensitive) against workspace-relative paths with empty and `.` segments dropped, so patterns are normalized the same way (`./src/./a.ts` → `src/a.ts`, and a trailing `/`, also inside a brace group, becomes `/**`), and an absolute, home-relative, drive-letter or `..` pattern is skipped with a warning. Because a glob Codewhale cannot compile makes it hold every write, rulesync writes only `*`, `?` and `**`: a `[...]` class widens to `?`, and `{a,b}` groups are expanded into separate `paths` (so an empty alternative keeps its meaning — `.env{,.local}` holds both `.env` and `.env.local`, where `globset` would drop the empty one); a pattern with a `\\` escape, a control character, a lone surrogate, unbalanced braces or more than 64 expansions is skipped with a warning. A `codewhale.rules` passthrough is only written in global scope. rulesync replaces only the invariants it wrote (marked `managed_by: \"rulesync\"`, a key Codewhale ignores) and keeps every other key (`authority`, `branch_policy`, `verification_policy`, `escalate_when`, ...) and every hand-written invariant; a restriction that a hand-written invariant already holds is not repeated, but only when the hand-written path is spelled exactly as rulesync would write it or as every canonical pattern that maps to it (such as an imported `docs/{a,b}.md`, as long as `globset` reads that spelling the same way) and both are plain printable ASCII: Codewhale only trims a pattern, so a hand-written `./.env` does not hold `.env`. A hand-written entry Codewhale cannot parse (which makes it hold every write for approval) is kept and reported with a warning. Codewhale reads only the nearest constitution between the workspace and its git root, so creating one below an existing one prints a warning. A new file starts with `schema_version: 1`; the file is never deleted, is not created when nothing maps, and an existing file that is not a valid JSON object, or whose `protected_invariants` is neither an array nor `null`, is an error. On import, every enforced invariant (hand-written ones included; advisory strings, objects without usable `paths` and entries Codewhale cannot parse, such as `\"action\": null`, are skipped) becomes a `write` and an `edit` rule per path, `block` as `deny` and `ask` as `ask`. A `block` denies the write in every posture; an `ask` prompts only in the Ask posture and is a hard block in Auto-Review, Full Access and Never, or whenever auto-approval is on. Shell commands that write a protected path are not held by repo law. See the [enforced repo-law invariants docs](https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md#enforced-repo-law-invariants).\n\nA `deny` on every shell command — the catch-all `*` (or `**`) pattern of `bash` or of the all-tools `*` category — also sets the top-level `allow_shell = false` in the per-project overlay `.codewhale/config.toml`, which hides Codewhale's shell tools for sessions in that workspace (Codewhale decides by action before specificity, so `allow` rules beside such a deny would not let a command through anyway). Only that key is written: the overlay's other keys (`model`, `approval_policy`, `sandbox_mode`, ...) are kept, the file is not touched when shell is not denied as a whole, and an existing file that is not valid TOML is an error when it has to be written. The overlay can only tighten, and a hand-written `allow_shell = false` cannot be told apart from one rulesync wrote, so rulesync never removes it: when the canonical file no longer denies every shell command, a warning asks you to delete it by hand (or to add the matching `bash` deny). On import, `allow_shell = false` becomes `bash: { \"*\": \"deny\" }`. See the [per-project overlay docs](https://github.com/Hmbown/Codewhale/blob/main/docs/CONFIGURATION.md#per-project-overlay-485).\n\nFor the GitHub Copilot CLI (`copilotcli`), this manages the two URL lists in the CLI's settings file: `.github/copilot/settings.json` (project mode, repository scope — the file [shipped in CLI v1.0.60](https://github.com/github/copilot-cli/blob/main/changelog.md)) and `~/.copilot/settings.json` (global mode, user scope). Only the canonical `webfetch` category maps: `allow` → `allowedUrls`, `deny` → `deniedUrls`, and an `ask` rule is represented by **omitting** the pattern, since the CLI prompts for any URL that is in neither list. No other category is emitted — the CLI's `permissions.allow`/`ask`/`deny` rule arrays are accepted only in MDM/enterprise managed settings, and interactive tool approvals are machine-written to `permissions-config.json`, so neither is authorable here. **Scope matters:** the repository-scope key table documents `deniedUrls` (union — a repository may add denials, never remove them) but **not** `allowedUrls`, so an allow rule is only enforceable at user scope; at project scope allow rules are dropped with a warning telling you to author them with `--global`, rather than being written to a key the CLI ignores (v1.0.79 additionally warns on startup about unknown top-level keys in the user `settings.json`, so Rulesync emits documented keys only there too). On import, a project-scope `allowedUrls` is likewise ignored so a dead entry does not become an enforced allow rule, and a pattern present in both lists imports as `deny`. `settings.json` also carries unrelated keys (`model`, `effortLevel`, `hooks`, `sandbox.*`, …), so Rulesync merges only the URL keys non-destructively and never deletes the file. See the [CLI config directory reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference).\n\nFor Kilo Code, this generates the `permission` object in `kilo.jsonc` (project mode) or `~/.config/kilo/kilo.jsonc` (global mode). The shape is identical to OpenCode's (Kilo is an OpenCode fork), so categories like `bash`, `read`, `edit`, `write`, `webfetch`, and `mcp` accept either a string catch-all (`\"allow\" | \"ask\" | \"deny\"`) or a `{ <pattern>: <action> }` map. Other top-level keys in `kilo.jsonc` are preserved on round-trip. **The `permission` object is merged per top-level tool key**: for each tool key present in the rulesync output, that key is replaced entirely from rulesync (rulesync owns its managed keys; manual edits inside a managed key will be overwritten on the next generation). Tool keys that exist in the existing `kilo.jsonc` but are NOT in the rulesync output are preserved verbatim so user-added Kilo-only categories survive regeneration. When a regenerate replaces a key whose existing value contained `deny` patterns that disappear from the new rulesync output, an aggregated `logger.warn` enumerates the dropped patterns (matching the project convention used by every other permissions translator). **Exact denies are written last**: Kilo evaluates the `permission` object last-match-wins in key order, and new keys are appended after the existing ones. Every key whose value is a plain `\"deny\"` and whose name contains no `*` or `?` is therefore moved to the end of the object, so a wildcard key written after it — in particular a catch-all `\"*\": \"allow\"` — cannot lift it, including the `{server}_{tool}` denies the MCP feature writes for `disabledTools`. An exact key matches only its own permission name, so this only ever makes that name stricter; every other key keeps its authored order (a `\"*\"` deny written after a category `allow` still wins). An `\"allow\"`/`\"deny\"` (plain, or a `{\"*\": …}` map) naming a tool of a server listed in the same file's `mcp` block is not imported into the `kilo` override: the MCP feature imports it as that server's `enabledTools`/`disabledTools` and owns it. Edits to other top-level keys (e.g. `model`) are preserved. **Malformed `kilo.jsonc` aborts the run**: the `jsonc-parser` library would otherwise silently coerce a syntax error to `{}` and overwrite the corrupted file with an empty `permission`, dropping the user's existing `deny` rules. Rulesync now surfaces parse errors so the run aborts before any destructive write — matching the strict `JSON.parse` behavior used by every other permissions translator.\n\n> **Kilo-only override (`kilo` key):** Kilo's `permission` object carries tool-specific keys with no canonical permission category — OpenCode-inherited ones (`external_directory`, `doom_loop`, `lsp`, `question`, `todowrite`, `skill`, `task`, `list`) and Kilo-unique ones (`agent_manager`, `notebook_read`, `notebook_edit`, `notebook_execute`, `repo_clone`, `repo_overview`). Add a tool-scoped `kilo` override key alongside the shared block (mirroring the `opencode` override) to author these; entries under `kilo.permission` are merged on top of the shared block **per key** (the override wins) and are emitted **only** into `kilo.jsonc`. Each value may be a bare action string or a pattern map. On **import**, any Kilo key that is not a shared canonical category (`bash`, `read`, `edit`, `webfetch`, `websearch`, `grep`, `glob`, the all-tools key `*`, or an `mcp__*` tool name) is routed into the `kilo` override rather than the shared block, so a subsequent `rulesync generate` does not leak it into other tools.\n>\n> **Kilo-only override (`kilo.sandbox`):** the `sandbox` block Kilo runs commands in is a security surface orthogonal to per-tool allow/ask/deny, with no canonical category, so it is authored under the same tool-scoped `kilo` override: `enabled` (boolean), `network` (e.g. `\"deny\"`), `allowed_hosts` (a list of `host` / `host:port` destination exceptions) and `writable_paths`. It is shallow-merged into the top-level `sandbox` key of `kilo.jsonc` — the override's keys win, unrelated sibling keys you set directly are preserved — and the whole block round-trips back into `kilo.sandbox` on import. **Scope matters here.** Kilo honors `allowed_hosts` and `writable_paths` from the _global_ config only, and lets a project config merely tighten (`enabled: true`, `network: \"deny\"`); a project-level network denial even clears the global destination exceptions. Rulesync mirrors that rather than writing config Kilo would ignore: at project scope only `enabled` and `network` are emitted, and any other key is dropped with a warning telling you to author it with `--global`. See the [sandboxing docs](https://kilo.ai/docs/getting-started/settings/sandboxing).\n\n> **Kilo `markdown_source` scope:** Kilo grants `permission.markdown_source` (external Markdown command sources, written as `\"<absolute>/*\": \"allow\"` patterns) only when the last matching rule comes from the _global_ config and says `allow` — \"Project configuration cannot grant this permission\" ([workflows docs](https://kilo.ai/docs/customize/workflows)). A project-level rule that matches last therefore always blocks, whatever its action: `deny` blocks, `ask` blocks just like `deny` (Kilo never prompts for this permission), and a project `allow` would block too, the opposite of what it says. So when `markdown_source` is generated at project scope — whether it comes from the shared `permission` block or from `kilo.permission` — `deny` and `ask` entries are written and `allow` patterns are dropped with a warning telling you to author it with `--global`; if nothing survives, the key is removed from the project `kilo.jsonc`. Global scope writes the rule verbatim.\n\n> **Name-mismatch traps.** Canonical category names do not always match Kilo's key names: Kilo folds **`write` into `edit`** (there is no `write` key), uses **`notebook_edit`** (not the canonical `notebookedit`) and **`task`/`agent_manager`** (not `agent`), and has **no `mcp` key** (MCP is addressed via `mcp__*` tool-name keys). Rulesync passes key names through verbatim, so author Kilo keys using Kilo's own names (e.g. put a `notebook_edit` rule under `kilo.permission`, not the canonical `notebookedit`). Kilo also treats a `null` action as a delete sentinel; Rulesync does not model `null` and only round-trips `allow`/`ask`/`deny`.\n\nFor MiMo Code, this generates the `permission` object in `.mimocode/mimocode.jsonc` (project mode; an existing `.mimocode/mimocode.json` is updated instead) or `~/.config/mimocode/mimocode.jsonc` (global mode). MiMo Code is an OpenCode distribution with the same permission schema, so everything described for OpenCode above applies, with the tool-scoped override under a `mimocode` key (`mimocode.permission`) instead of `opencode`; on import, keys that are not shared canonical categories are routed into that `mimocode` override.\n\nFor AugmentCode CLI, this generates `toolPermissions` entries in `.augment/settings.json` (project mode) or `~/.augment/settings.json` (global mode). The file is read as JSON with Comments, as Auggie [documents](https://docs.augmentcode.com/cli/config), and only the `toolPermissions` key is rewritten, so comments elsewhere in it survive a regenerate. Each entry has `toolName`, an optional `shellInputRegex` (only for shell commands), and `permission.type` ∈ `\"allow\" | \"deny\" | \"ask-user\"`. Tool category mapping: `bash` → `launch-process`, `read` → `view`, `edit` → `str-replace-editor`, `write` → `save-file`, `webfetch` → `web-fetch`, `websearch` → `web-search`. Auggie's [permissions reference](https://docs.augmentcode.com/cli/permissions) now names these tools `terminal` / `read` / `edit` / `write` and lists the legacy names as aliases of them; Rulesync keeps emitting the legacy names, which the shipped CLI (0.36.0 as of this writing) matches exactly, but treats an existing entry authored under a current name as the same managed tool — it imports as the same canonical category (a `terminal` entry's `shellInputRegex` is recovered like a `launch-process` one), a stale `terminal` / `read` / `edit` / `write` `allow` or `ask-user` row is replaced on regenerate instead of surviving as an unmanaged entry, and a `deny` row under either spelling is preserved as written — its spelling is not rewritten — unless a generated entry re-emits the same shape under the same spelling or the legacy name. Action mapping: rulesync `ask` → AugmentCode `ask-user`. For `bash` patterns other than `*`, the glob pattern is converted to a regex and emitted as `shellInputRegex`. The glob → regex conversion maps `*` to `.*`, `?` to `.`, escapes `\\^$.|+(){}[]`, and anchors at both ends; characters outside that set (notably `-`, `/`, `:`, `,`) are emitted verbatim, so Augment will match them literally. Generated entries are sorted **deny first, ask second, allow last**, with more specific patterns (those carrying `shellInputRegex`) before catch-alls — this is required because Augment's `toolPermissions` is evaluated **first-match-wins**. Existing `toolPermissions` entries whose `toolName` is NOT in the rulesync-managed set are preserved on round-trip, with one exception: a legacy `toolName: \"*\"` basic entry — an inert artifact of older rulesync versions, since AugmentCode only matches exact tool names — is always dropped. Existing **`deny` entries for ANY managed `toolName`** (`launch-process`, `view`, `str-replace-editor`, `save-file`, `web-fetch`, `web-search`, or the current aliases `terminal` / `read` / `edit` / `write`) are also preserved (fail-closed) so a user-added deny rule on any managed tool cannot be silently downgraded by regeneration. Existing managed-tool `allow` / `ask-user` entries are still replaced (rulesync owns the permissive surface for managed namespaces). **Non-bash categories do not have a documented per-input matcher in AugmentCode**, so Rulesync emits at most one catch-all entry per tool: if the rulesync category contains any `deny` rule, Rulesync emits a single `deny` entry for the entire tool (fail-closed) and warns; otherwise only `*`-pattern allow/ask rules are emitted and any non-`*` allow/ask patterns are dropped with a warning. **AugmentCode has no wildcard `toolName`**, so the canonical all-tools `*` category is never emitted as an entry of its own: any `deny`/`ask` rule it carries is folded into `launch-process` — via the same glob shadowing logic bash rules use elsewhere — even when the source states no separate `bash` category, and warns once. The same restriction is also applied as a blanket `deny`/`ask` fallback to any of `view` / `str-replace-editor` / `save-file` / `web-fetch` / `web-search` that would otherwise emit **no entry of its own**; a category that states only non-`*` patterns still counts as emitting nothing, since those patterns are dropped, so it still falls back rather than silently defaulting to AugmentCode's own permissive behavior. An all-tools `allow` never forces an entry, and a tool whose own rules do emit an entry is always left untouched. Importing AugmentCode entries back into rulesync recovers `bash` patterns from `shellInputRegex` but the other categories always import as the catch-all `*` pattern. **The import direction also applies fail-closed precedence** when multiple existing entries collapse to the same `(canonical, \"*\")` key (e.g. `[{view: deny}, {view: allow}]`): the most restrictive action wins regardless of iteration order (precedence: `deny` > `ask` > `allow`), so a user-added deny in the source file is never silently dropped by import order. The `launch-process` (bash) path is unchanged because each entry has its own `shellInputRegex`-derived pattern with no `\"*\"` collapse. On **import** (project scope), Rulesync also reads the layered overrides file `<workspace>/.augment/settings.local.json` — a gitignored, machine-specific file that Auggie merges on top of `settings.json` — and combines it over the base settings before converting to the canonical model, following Auggie's documented layering (simple values take the local override, `mcpServers`/`plugins` replace wholesale, and other objects/lists — including `toolPermissions`, which Auggie concatenates local-first under first-match — are combined across tiers), so personal permission overrides are picked up without dropping a committed base `deny`. This overlay is **import-only and project-only**: Rulesync never writes `settings.local.json` (it stays a user-owned, gitignored file), and AugmentCode documents no global `~/.augment/settings.local.json`, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Every other top-level key is preserved verbatim through the generate round-trip; the plugin-consumption keys `recommendedMarketplaces` and `enabledPlugins` are rewritten only when the `augmentcode` override below states them.\n\n> **AugmentCode-only override (`augmentcode` key):** AugmentCode's `toolPermissions[]` supports \"custom policy\" entries the canonical allow/ask/deny model cannot express — `permission.type` of `webhook-policy` / `script-policy` (delegating the decision to a `webhookUrl` / `script`) and an `eventType` of `tool-response` (a post-execution check rather than the default pre-execution `tool-call`). Author these through a tool-scoped `augmentcode` override with a `toolPermissions` array of verbatim entries: `{ \"permission\": { … }, \"augmentcode\": { \"toolPermissions\": [ { \"toolName\": \"github-api\", \"permission\": { \"type\": \"webhook-policy\", \"webhookUrl\": \"https://api.example.com/validate\" } }, { \"toolName\": \"view\", \"eventType\": \"tool-response\", \"permission\": { \"type\": \"allow\" } } ] } }`. Authored entries are **prepended** — ahead of the canonical-generated basic rules — so a webhook/script gate or tool-response check is never shadowed by a regenerated allow/deny/ask entry under first-match-wins. When the override authors `toolPermissions` it becomes the source of truth for the special entries (the existing file's specials are no longer separately preserved, avoiding a double-emit); without an override, any special entries already present in `settings.json` are preserved verbatim as before. On **import**, special entries are lifted verbatim into the `augmentcode` override (rather than being skipped with a warning) so they round-trip and become user-authorable; basic entries continue to drive the shared `permission` block. The entry objects stay a loose passthrough so `shellInputRegex`, `webhookUrl`, `script`, and future non-policy fields survive untouched, while the documented bounded fields are validated as enums: `permission.type` (`allow` | `deny` | `ask-user` | `webhook-policy` | `script-policy`) and `eventType` (`tool-call` | `tool-response`). Both project and global scope are supported.\n>\n> The same override also authors Auggie's [plugin-consumption settings](https://docs.augmentcode.com/cli/plugins), so a repository can tell its contributors which plugin marketplaces and plugins to use: `{ \"augmentcode\": { \"recommendedMarketplaces\": [\"acme/team-plugins\"], \"enabledPlugins\": { \"code-guard@acme-plugins\": true } } }`. `recommendedMarketplaces` is a list of `\"owner/repo\"` GitHub marketplaces that Auggie offers to install when someone opens the workspace; Auggie honors it only in the project's `.augment/settings.json` and ignores it in user or local settings, so it replaces the project list and is skipped with a warning under `--global`. `enabledPlugins` maps plugin ids (`\"plugin-name@marketplace-name\"`) to `true`/`false` and is written at either scope. Each key is written only when the override states it — otherwise a value already in the file is left untouched — and a stated key replaces the value in the file, so a plugin id dropped from the override is no longer enabled by the project settings (Auggie deep-merges `enabledPlugins` across tiers, so the user and local tiers still apply). Because the stated value owns the whole key, an entry added to `settings.json` after the last import — for example by `auggie plugin install --project` — is removed on the next generate, with a warning naming it; add them to the override, or run `rulesync import --targets augmentcode --features permissions` after installing plugins with Auggie, to keep them. Removing a key from the override altogether does not delete the value an earlier generate wrote; clear it in `settings.json` by hand. Writing a non-empty `recommendedMarketplaces` or an enabled plugin that differs from the value already in the file is logged as a warning, since both make Auggie load code the repository does not ship (a plugin can carry its own hooks and MCP servers). On **import**, both keys are lifted into the `augmentcode` override when they have the documented shape, so a plugin enabled with `auggie plugin install --project` survives the round-trip. Both are read from the committed `settings.json` only: a `recommendedMarketplaces` in `settings.local.json` is ignored, as Auggie ignores it, and an `enabledPlugins` there is not imported, so a plugin enabled for one machine is never promoted to the shared settings. A global import skips `recommendedMarketplaces`, which Auggie ignores in `~/.augment/settings.json`. `dismissedMarketplaces` (managed by Auggie in `settings.local.json`) and `autoUpdateMarketplaces` (a personal user setting) are not authored.\n\nFor Factory Droid, this generates `commandAllowlist` / `commandDenylist` arrays in `.factory/settings.json` (project mode) or `~/.factory/settings.json` (global mode). Factory Droid only gates **shell commands** through these two lists, so the rulesync `bash` category is what is translated: `allow` patterns become `commandAllowlist` entries (run without confirmation) and `deny` patterns become `commandDenylist` entries (always require confirmation; the denylist wins when a command is in both). Factory Droid has **no separate `ask` list** — any command not in the allowlist already prompts — so rulesync `ask` rules write nothing, though an `ask` does withhold every `allow` that names any of the same commands (an `ask` on `npm *` withholds an allowed `npm publish`, an `ask` on `npm publish` withholds an allowed `npm *`, and an `ask` on `* --force` withholds an allowed `git *` even though neither pattern covers the other's spelling — the two overlap on every `git … --force` command), because canonically the stricter rule wins whatever its width, so auto-approving a command the file also asks about would answer the prompt the author wanted. The all-tools `*` category is read as well, but only for its **restricting** rules: a `deny` written there covers shell commands too, so it lands in `commandDenylist` — skipping it would auto-approve the very command the file blocks. A `bash` deny keeps the `allow` beside it, since the denylist outranks the allowlist and a `bash` pattern names a command by construction: `{\"bash\": {\"git *\": \"allow\", \"git push *\": \"deny\"}}` blocks the push and auto-approves the rest. A deny written under `*` is entered in the denylist too — for the case where it _is_ a command — but it **also** withholds the `allow` rules it covers, because a pattern there need not name a command at all: `{\"*\": {\"secrets/**\": \"deny\"}}` beside an allowed `*` would otherwise auto-approve every command while the denylist entry matches none of them. A `*` deny that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported: nothing observed says it names a command, so the denylist entry may block nothing at all — write it under `bash` too if it is a command pattern. A config with no `allow` rules at all, or one that spells the same pattern under `bash` as well, is not reported, since neither says anything about whether the pattern names a command. A `bash` `ask` that withheld no `allow` is **not** reported: Droid prompts for whatever its allowlist does not cover, so an `ask` on a command no `allow` overlaps is already honored exactly as written. An `ask` under `*` writes nothing and withholds the same way a `bash` one does, but one that withheld nothing is reported on the same terms as the `*` deny above — a pattern there need not name a command, and withholding is the only way an `ask` can restrict here, so nothing observed says the rule does anything at all. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction, at the cost of auto-approving less than the config asked for — and says so in a warning. This read is one-way: import writes patterns back into the `bash` category only, never into `*`. Its `allow` rules are deliberately never carried over, since a pattern under `*` need not be a command at all (`secrets/**` there denies a path) — the skip is named in a warning rather than made silently; both directions therefore fail closed. Categories other than `bash` and `*` cannot be represented in the command allow/deny model and are skipped, with a `logger.warn` when a skipped category carries a `deny` or an `ask` rule (to surface the gap — a foreign `ask` restricts as surely as a foreign `deny`). rulesync owns the `commandAllowlist` / `commandDenylist` keys (they are replaced from the rulesync output), while every other key in `settings.json` (e.g. `hooks`) is preserved verbatim on round-trip — except the Factory-specific security keys covered by the `factorydroid` override below, which are lifted into that override on import. Importing reads the two lists back into the `bash` category, with `.factory/settings.local.json` overlaid on top of `settings.json` first — Droid layers the two, so importing without the overlay would read permissions it does not actually enforce. Rulesync never _writes_ `settings.local.json`; generation only ever writes `settings.json`. Note the consequence for a full round-trip: a value that came from the local file is imported into `.rulesync/permissions.jsonc` like any other, so the next `generate` writes it into the shared `settings.json`. If a personal override should stay personal, drop it from `.rulesync/permissions.jsonc` after importing. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Rulesync gitignores `.factory/settings.local.json` itself, matching Factory's guidance to keep it out of version control.\n\n> **Factory Droid-only override (`factorydroid` key):** Factory Droid has security controls that do not fit the per-command `allow`/`ask`/`deny` model — the hard-block `commandBlocklist` tier (commands that can **never** run, not even under full autonomy — distinct from an approvable `deny`), plus `networkPolicy` (`allowedIps`), `sandbox` (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`, autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`, `subagentAutonomyLevel`, `interactionMode`, and the per-tool `mcpAutonomyOverrides`), the plugin-bootstrap keys `extraKnownMarketplaces` / `enabledPlugins` (Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), the `hooksDisabled` kill-switch, `disabledSkills` (an array of skill names to disable without deleting their files), and the organization controls `modelPolicy` and `missionPolicy`. Add a tool-scoped `factorydroid` override to author them: its keys are merged into `settings.json` (the override wins) while the shared `permission` block keeps driving `commandAllowlist`/`commandDenylist`. On **import**, these keys are lifted into the `factorydroid` override — so `commandBlocklist` now round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvable `deny`.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"factorydroid\": { \"commandBlocklist\": [\"curl *\"], \"sandbox\": { \"enabled\": true } }\n> }\n> ```\n\nFor Cline CLI, this generates `.cline/command-permissions.json` (project mode only). Cline reads this file via the `CLINE_COMMAND_PERMISSIONS` environment variable; you can wire it up with `export CLINE_COMMAND_PERMISSIONS=$(cat .cline/command-permissions.json)`. The schema is `{ \"allow\": [...], \"deny\": [...], \"allowRedirects\": false }`. Cline only supports shell commands and only `allow`/`deny`. Categories other than `bash` and the all-tools `*` are dropped, and `bash` `ask` rules are **translated to `deny`** (fail-closed safety, since Cline lacks `ask` semantics). The all-tools `*` category contributes its **restricting** rules — a `deny` written there covers shell commands too, so skipping it would auto-approve a command the file blocks — while its `allow` rules are never carried over, since a pattern under `*` need not be a command at all (the skip is named in the translation notice rather than made silently); both directions therefore fail closed. A `bash` `deny` is written to `deny` and keeps the `allow` beside it — [Cline documents that deny rules always take precedence](https://docs.cline.bot/cli/cli-reference), and a `bash` pattern names a command by construction — which is how a narrow `deny` carves an exception out of a wider `allow` (`git *` allowed, `git push` denied). A `deny` under `*` is written as well, but it **also** withholds the `allow` rules it covers, since a pattern there need not name a command: `{\"*\": {\"secrets/**\": \"deny\"}}` beside an allowed `*` would otherwise auto-approve every command while the `deny` entry matches none. A `*` deny that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported the same way, since nothing observed says it names a command Cline can block. An `ask` under `*` is **not** translated: a pattern written there need not name a command, and denying it outright would turn the ordinary catch-all `{\"*\": {\"*\": \"ask\"}}` into a block on every command — one the additive `deny` merge below would then keep forever. It **withholds** the `allow` rules it covers instead (whatever the two patterns' widths, since canonically the stricter rule wins regardless of how wide it is), which leaves those commands prompting, exactly as an `ask` asks. A `*` ask that had `allow` rules to overlap, overlapped none of them, and is not also written under `bash` is reported: withholding is the only way it can restrict here, so nothing observed says the pattern names a command — write it under `bash` if it is one. One that withheld no `allow` at all is not reported, since the `allow` array below is a gate: a command no `allow` covers already prompts, which is what the ask asked for. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction — and says so in the translation notice. Note that Cline's `allow` array is a gate rather than a list of exceptions — once it is present, only the commands matching it run without approval — so a config whose every `allow` is withheld asks about every command. The `*` read is one-way: import writes patterns back into the `bash` category only, never into `*`. All of those translation notices are surfaced via a single aggregated `logger.warn` per generation (matching the project convention used by every other permissions translator) so the translation stays visible without tripping CI gates that treat error lines as failures. **The `allow` array is wholesale-replaced by rulesync** — user-added entries inside `allow` are not preserved on regenerate. **The `deny` array is additive** — user-added denies in the existing file are preserved on every generation alongside the rulesync-derived denies (fail-closed standard). The `allowRedirects` field (a single global boolean gating shell redirection operators `>`/`>>`/`<`) can be authored from rulesync via a tool-scoped **`cline` override** — add `\"cline\": { \"allowRedirects\": true }` alongside the shared `permission` block. Precedence: the `cline` override wins, otherwise the existing file value is preserved, otherwise it defaults to `false`. On import, a `true` value round-trips back into the `cline` override (the default `false` emits no override). Cline does not have a stable per-user file location for command permissions, so global mode is not supported. If a pattern still ends up in **both** `allow` and `deny` — which the additive merge can produce when the existing file carries a hand-written `deny` matching a generated `allow` — Rulesync emits a warning, since deny wins and the `allow` entry therefore has no effect.\n\nFor Zed, permissions are global-only and generate the `agent.tool_permissions` object in `~/.config/zed/settings.json` (`%APPDATA%\\Zed\\settings.json` on Windows). Zed reads a project `.zed/settings.json` as [`ProjectSettingsContent`](https://github.com/zed-industries/zed/blob/main/crates/settings_content/src/project.rs), which has no `agent` field, so no `agent.*` key written there takes effect; project-mode generate and import therefore skip Zed permissions, and an `agent` block an earlier Rulesync version wrote into `.zed/settings.json` is inert and left for you to remove by hand. Each canonical category becomes a key under `agent.tool_permissions.tools.<tool>` (tool-name mapping: `bash` → `terminal`, `edit` → `edit_file`, `write` → `write_file`, `webfetch` → `fetch`, `websearch` → `search_web`; unknown categories pass through unchanged). Per-tool MCP categories are translated: canonical `mcp__<server>__<tool>` becomes Zed's `mcp:<server>:<tool>`, and imports back into the canonical spelling, so a category authored once reaches Zed and the other targets alike (a key already written in Zed's spelling is emitted unchanged but still normalizes to the canonical form on import). Only the first separator is split, so a tool whose own name contains `__` survives the round-trip. Inside an MCP category only the catch-all `*` rule is emitted, as the tool's `default`; pattern-scoped rules are dropped with a warning, because Zed dispatches every MCP tool with a single empty input (\"MCP tools are gated only by tool id (no per-input pattern matching)\"), so a pattern would be matched against `\"\"` rather than against anything meaningful. A category that omits or wildcards either half — `mcp__<server>`, `mcp__<server>__*`, `mcp__*__<tool>` — is dropped with a warning too, since Zed looks the tool up by exact key on the full triple with no glob or prefix matching. Any canonical-spelled `mcp__<server>__<tool>` entry an earlier Rulesync version left in `settings.json` is swept on the next generate, whether or not the current config still names that category: it is not a Zed tool name, so it can only be Rulesync's own output, and leaving it would resurrect stale rules on the next import. Read-only categories are not written at all: Zed's [gated tool list](https://zed.dev/docs/ai/tool-permissions#supported-tools) does not include `read_file`, `grep`, `find_path` or `list_directory` — they are in Zed's own `EXCLUDED_TOOLS` and never consult the permission settings, so neither a per-tool entry nor the global `default` reaches them. Canonical `read`, `grep` and `glob` (and a category naming one of those Zed tools directly) are therefore **dropped**, with a warning when the category carried a `deny` or `ask` rule, rather than written as entries Zed ignores. Zed's read-denial surface is `private_files`, which the ignore feature writes from `.rulesync/.aiignore`. An inert entry an earlier Rulesync version wrote is left in place rather than deleted — Rulesync cannot tell it from one you wrote, and Zed ignores it either way — and it still imports back as the canonical category, so remove it by hand if you want it gone. The canonical `*` category is the exception: its catch-all `*` rule sets the top-level `agent.tool_permissions.default` — rung 6 of Zed's precedence ladder, and the mechanism Zed documents for MCP tools — rather than an inert `tools[\"*\"]` entry (`*` is not a Zed tool name; a stale `tools[\"*\"]` entry written by an earlier version is cleaned up when the canonical config carries a `*` category, and the `default` imports back as `*: { \"*\": <action> }`). Pattern-scoped rules in the `*` category have no Zed counterpart and are dropped with a warning. Within every other category, the catch-all `*` pattern sets the per-tool `default`, while specific patterns become `always_allow` / `always_deny` / `always_confirm` entries of the form `{ \"pattern\": <regex>, \"case_sensitive\": false }`. Action mapping: rulesync `ask` ⇄ Zed `confirm` (`allow`/`deny` are shared). Because Zed matches with regular expressions, patterns are emitted verbatim — author canonical patterns as regexes when targeting Zed. The settings file is shared with the MCP (`context_servers`) and ignore (`private_files`) features, so writes merge non-destructively: unrelated settings, a user-set `agent.tool_permissions.default` (when the canonical config has no `*` category), and any `tools.<tool>` entries NOT managed by rulesync are preserved on round-trip. The canonical model has no slot for per-pattern case sensitivity, so rulesync always emits `case_sensitive: false`; a hand-authored `case_sensitive: true` on a rulesync-managed tool is overwritten on the next generate.\n\n> **Zed-only override (`zed` key):** three Zed `agent` keys sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scoped `zed` override. `zed.sandbox_permissions` is written into `agent.sandbox_permissions`: Zed's OS-level agent sandbox, which since [Zed 1.14.2](https://zed.dev/releases/stable) (2026-08-05) is **on by default** for the `terminal` and `fetch` tools and by default forbids network access, writing outside the project directories, and writing to `.git`. Most real setups therefore need to relax one of `network_hosts` (exact hostnames or leading `*.` wildcards), `allow_all_hosts`, `write_paths`, `allow_fs_write_all` or `allow_unsandboxed` — none of which the canonical categories can express, since this is process containment rather than tool gating. `zed.profiles` is written into `agent.profiles`, Zed's tool-availability layer: a separate enforcement stage from `tool_permissions`, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keys `name`, `tools`, `enable_all_context_servers`, `context_servers`, `default_model`). `zed.default_profile` is written into `agent.default_profile`, the profile the agent starts in (Zed's default is `write`), so an authored profile can be selected without picking it by hand in the agent panel; Zed itself rewrites this key whenever you switch profiles there. Example: `{ \"permission\": { … }, \"zed\": { \"sandbox_permissions\": { \"network_hosts\": [\"*.github.com\"], \"write_paths\": [\"/tmp\"] }, \"profiles\": { \"review\": { \"name\": \"Review\", \"tools\": { \"terminal\": false } } }, \"default_profile\": \"review\" } }`. The `sandbox_permissions` and `profiles` blocks pass through untouched — Rulesync canonicalizes neither, and validates only the documented `profiles` keys (`sandbox_permissions` is unvalidated, since Zed adds to it release over release) — and each is **replaced wholesale** when the override supplies it, since Zed reads each as a single policy unit; omit the key and whatever is already in `settings.json` is left alone rather than deleted, so removing a block is a manual edit. On **import**, all three are lifted back into the `zed` override so a hand-written sandbox policy, profile set or profile selection round-trips — including approvals you did not write by hand, since Zed saves an always-allow you clicked in the sandbox prompt into `agent.sandbox_permissions` itself. Read the imported block before committing it: an ad-hoc `allow_unsandboxed` picked up from your own machine would otherwise be committed to `.rulesync/permissions.jsonc` and regenerated into the user settings of everyone who runs a global generate from it. The same wholesale replace works the other way too — regenerating from an authored override discards approvals Zed had recorded since. The `zed` block authors these three keys and nothing else: `agent.tool_permissions` belongs to the canonical `permission` block, and any other key — a misspelling, or a blunt instrument such as Zed's `agent.always_allow_tool_actions` — is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Like the sibling `agent.tool_permissions`, these keys are written in global scope only, since Zed reads no `agent` key from a project `.zed/settings.json`. See the [Zed sandboxing](https://zed.dev/docs/ai/sandboxing) and [agent profiles](https://zed.dev/docs/ai/agent-profiles) docs.\n\nFor Qwen Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.qwen/settings.json` (project mode) or `~/.qwen/settings.json` (global mode). The format mirrors Claude Code's: entries are `Bash(<pattern>)`, `Read(<pattern>)`, `Edit(<pattern>)`, `Write(<pattern>)`, `WebFetch(<pattern>)`, `WebSearch(<pattern>)`, `Grep(<pattern>)`, `Glob(<pattern>)`, `Agent(<pattern>)`, etc. Other top-level keys in `settings.json` are preserved on round-trip. Patterns may contain nested parentheses (e.g. `Bash(echo (a))`); Rulesync uses the **last** `)` as the closing delimiter when parsing, so inner parens round-trip. Malformed entries (missing closing paren, trailing characters) emit a warning; for **`deny`** they fall back to the catch-all pattern `*` (fail-closed: broadening a deny is the safer direction), but for **`allow` / `ask`** they are **dropped** rather than broadened — silently turning a narrow user rule into `*` would be a fail-open round-trip. Generation does not create the `.qwen/` directory until `writeAiFiles` runs, so dry-run is side-effect-free.\n\nFor Kimi Code, permissions are global-only and generate `[[permission.rules]]` entries in `~/.kimi-code/config.toml`. Canonical categories map to Kimi tool patterns (`bash` → `Bash`, `read` → `Read`, `write` → `Write`, `edit` → `Edit`, `grep` → `Grep`, `glob` → `Glob`, `websearch` → `WebSearch`, `webfetch` → `FetchURL`, `agent` → `Agent`, and `mcp__…` passes through as the MCP tool name); a `*` canonical pattern emits the bare tool name and a specific pattern emits `Tool(pattern)`. Actions map 1:1 to Kimi's `allow` / `ask` / `deny`, and generated rules use `scope = \"user\"`. Kimi evaluates rules first-match-wins, so Rulesync sorts canonical output fail-closed: all `deny` rules precede `ask`, all `ask` rules precede `allow`, and more-specific patterns precede broader patterns within each action. Kimi does not match MCP tool arguments; an argument-specific MCP `allow`/`ask` is skipped with a warning rather than broadened, while an argument-specific `deny` becomes a whole-tool deny with a warning. The optional `kimi-code.defaultPermissionMode` override writes Kimi's top-level `default_permission_mode` (`manual` / `yolo` / `auto`), while `kimi-code.rules` accepts native rules that canonical categories cannot express and emits them first in their authored order. On import, Rulesync preserves the complete ordered rule list under `kimi-code.rules`, including rules that could otherwise fit the shared permission model, so regeneration cannot change Kimi's first-match behavior. Rule entries that name the tool separately (`tool = \"Bash\"` with `match` or `pattern` for the argument) are folded into `Tool(arg)` the way Kimi reads them (`match` wins over `pattern`; a bare `tool` becomes the tool name), so they survive the round trip instead of being dropped. The `[permission]` `allow` / `deny` / `ask` array form is not read on import; it is left in place on regenerate like any other sibling key. A `kimi-code.tools` override writes Kimi's `[tools] enabled` / `disabled` lists — a separate enforcement layer from `[[permission.rules]]`, since a rule prompts while these remove the tool from every agent in every session. Entries pass through verbatim because the section uses agent-file tool syntax (exact built-in names, `mcp__server__*` globs) rather than the canonical category/pattern shape. Note that Kimi registers `[tools]` in its v2 engine, so today it applies under `kimi web` and experimental `kimi -p` rather than the interactive TUI. Like the MCP defaults, the section merges per key: authoring only `enabled` leaves a hand-written `disabled` list alone, and dropping the override leaves the section as it stands. Values are carried through exactly as written, empty lists included — `enabled = []` is an allowlist admitting _nothing_, the strictest setting there is, while an absent `enabled` means no allowlist at all, so the two are never interchanged. The `[permission]` table merges per key the same way: Rulesync owns only `rules`, so a hand-written sibling such as `dangerous_command_guard = false` (the Kimi Code 0.40.0 switch for the built-in dangerous-command policy) is carried through verbatim on regenerate rather than deleted. That switch can also be authored: the optional `kimi-code.dangerousCommandGuard` boolean override writes `[permission] dangerous_command_guard`, and import lifts a boolean value back into it. Omitting the override leaves an existing value in place. Kimi's `KIMI_CODE_DANGEROUS_COMMAND_GUARD` environment variable still takes precedence over the file. The TOML file is shared with hooks, the MCP timeout defaults and other Kimi settings, so updates merge in place and never delete the file. See the [Kimi Code permission docs](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html).\n\n> **Qwen-only override (`qwencode` key):** Qwen's `settings.json` exposes autonomy/sandbox controls with no canonical permission category — under `tools` (`approvalMode` = `plan`/`default`/`auto-edit`/`auto`/`yolo`, `autoAccept`, `sandbox`, `sandboxImage`, `disabled`, `visible` (the deferred-tool startup visibility list, union-merged by Qwen across scopes), `listDirectory` (the `{ enabled }` toggle for the built-in `list_directory` tool, off by default because `glob` covers directory listing — note that Qwen re-enables the tool anyway when `list_directory` is named in the `tools.core` allowlist, an unrelated sibling key the override preserves rather than owns), `todoWrite` (the `{ enabled }` toggle for the built-in `todo_write` tool and its system-prompt guidance, off by default since Qwen Code v0.23.1; `experimental.todoStopGuard` requires it), `eager` (the allowlist of eager-by-default tool names whose schemas stay in the initial model request; every other non-exempt one is demoted to deferred, still registered and callable but only after `tool_search` loads it — and out of reach for a session where `tool_search` is not registered. Unlike `visible` and `disabled`, Qwen does **not** union this list across scopes, so a project value replaces a user one outright, and an explicit `[]` defers every non-exempt tool while omitting the key means no restriction at all), `workflowsEnabled` (which enables the Workflow tool and the `/workflows` command), `workflowNameOnly` (which restricts that tool to named workflows, so every run the model starts can be matched by a `Workflow(name:...)` permission rule; since Qwen Code v0.24.1), and `executionSandbox` (the Linux tool-execution sandbox policy, `{ filesystem, network, backend? }`; since Qwen Code v0.24.4)), `security` (`folderTrust`, the two guardrails on `type: \"http\"` hooks — `allowedHttpHookUrls`, the allowlist of URL patterns a hook may POST to, where an empty list means allow-all, and `allowPrivateNetworkHooks`, which relaxes the private-IP (SSRF) check — and `allowedInsecureVoiceBaseUrls`, the base URLs a voice provider may be reached at over cleartext HTTP or a private address, which also carries the provider API key in its `Authorization` header), `skills` (the skill kill-switches: `disabled`, the hard-disabled skill names; `defaultDisabled`, the names that start disabled; `enabled`, the explicit opt-ins that override `defaultDisabled` — matched against the registered name, so an extension skill is written `rust:pdf`; and `disabledLevels`, the discovery levels — `project`, `user`, `extension`, `bundled` — to skip entirely. Precedence is `disabled` > `enabled` > `defaultDisabled`, and Qwen Code unions every one of these lists across scopes. This is the only way to turn off a bundled or extension skill that rulesync does not generate), `general` (`outputStyle`, the output style that shapes how responses are written: a built-in such as `Concise`, `default`, or the name of a custom style in `.qwen/output-styles/`; rulesync writes only the setting, not the style files, and leaves the other `general` keys alone), and `permissions.autoMode` (the Auto Mode classifier config: `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell`). Add a tool-scoped `qwencode` override to author them: `qwencode.tools`, `qwencode.security`, `qwencode.skills` and `qwencode.general` are shallow-merged into the matching `settings.json` group at the **top level of that group** (an unrelated sibling key such as `tools.core` is preserved, an override key wins, and a nested object the override supplies such as `security.folderTrust` replaces the existing one wholesale rather than being deep-merged), while `qwencode.autoMode` is emitted as `permissions.autoMode` (replacing the existing `autoMode` wholesale) and the shared `permission` block keeps driving the `permissions.allow`/`ask`/`deny` arrays. On import, the documented autonomy keys (`tools.{approvalMode,autoAccept,sandbox,sandboxImage,disabled,visible,listDirectory,todoWrite,eager,workflowsEnabled,workflowNameOnly,executionSandbox}`, `security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks,allowedInsecureVoiceBaseUrls}`, `skills.{disabled,defaultDisabled,enabled,disabledLevels}`, `general.outputStyle`, and `permissions.autoMode`) round-trip back into the override; other `tools`/`security`/`skills`/`general` keys (such as `skills.directories` or `general.vimMode`) are left in `settings.json` and not extracted. Scope caveats apply to six of those keys, because Qwen Code does not let a workspace decide everything a user can. Four of them — `tools.workflowsEnabled`, `tools.executionSandbox`, `security.allowPrivateNetworkHooks` and `security.allowedInsecureVoiceBaseUrls` — are on Qwen Code's workspace-restricted list and are **stripped** out of workspace settings before merging, honoring them in user/system settings only, so that a cloned repository cannot turn on a subagent-spawning tool, choose the sandbox its own commands run in, grant itself private-network access, or add a cleartext destination that its API key is sent to. Rulesync therefore skips those four with a warning when generating project-scoped `settings.json` (a value already written into the project file by hand is left untouched), and emits them only in global mode — where a granting value is announced, naming the key, the value and what it replaced. \"Granting\" follows Qwen Code's own reading: any truthy value for the booleans — so `1`, the string `\"false\"` and an empty array all count, because Qwen Code tests them for truth rather than for content — and a non-empty list for `allowedInsecureVoiceBaseUrls`, whose list it matches a URL against instead, making the empty one fail-closed — and any `executionSandbox` value, `null` included, since it is a policy rather than a switch. (`workflowsEnabled` also answers to the `QWEN_CODE_ENABLE_WORKFLOWS` / `QWEN_CODE_DISABLE_WORKFLOWS` environment variables, disable winning, so a settings value is not the last word.) The other two are written in both scopes, but not silently. `security.allowedHttpHookUrls` sits on Qwen Code's second, _non-overriding_ list: a workspace value is honored only while no user, system or system-defaults scope sets the key, so a repository may narrow where its own hooks send data but never replace the whitelist a user configured in a higher scope. It does replace a list written by hand in that same project file, though, and an empty list is Qwen Code's allow-all — the emptiest value being the widest one is why **every** global write that changes the file is announced, not just a granting one. `security.folderTrust` is read on both sides of the merge: the _initial_ trust decision — the one that settles whether this workspace's own settings are merged at all — is made from user and system settings alone and defaults to trusted, so a project value cannot vouch for its own repository; once the workspace is trusted, the merged value still drives the folder-trust gate, so `{ \"enabled\": false }` in a project file does switch that gate off. Rulesync emits it with a note saying which half it cannot decide, and announces a global write that changes it. Import lifts all six in either scope, because the file being read carries no scope marker. It flags them when the file it read was the project one, since that is the value regenerating with `--global` would promote into one Qwen Code enforces everywhere; a value imported from `~/.qwen/settings.json` is already there, so it is lifted silently. Review a flagged key before you regenerate globally, especially if the `.qwen/settings.json` came from a repository you cloned. The remaining keys are neither stripped nor non-overriding — Qwen Code honors them wherever they are written, with one tighten-only exception noted below — but a global write is still announced whenever it changes what the file said, because that is the write that settles how far the agent may go unattended on every project on the machine: the autonomy and containment controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), `tools.workflowNameOnly` (on Qwen Code's _tighten-only_ list: a workspace may set it to `true` but a workspace `false` is ignored, so a repository can add the named-workflows restriction yet never lift one a user or system setting imposes — the project-scope note says so), the registry controls (`disabled`, which keeps a tool from being registered at all — Qwen Code unions the disabled lists across scopes, so dropping a name only hands the tool back when no other scope still names it — plus `visible`, `listDirectory`, `todoWrite` and `eager` — which does not remove a tool but demotes every non-exempt eager-by-default one it omits to deferred, and replaces rather than unions the list a higher scope set), the Auto Mode classifier config (`permissions.autoMode`, whose replacement rewrites the instructions the classifier follows, including any deny hint already configured), the skill kill-switches (`skills.*` — Qwen Code unions each list across scopes, but the override replaces the list in the file it writes, so dropping a name hands a skill back unless another scope still names it, and `skills.enabled` opts into a skill another scope starts disabled), the output style (`general.outputStyle` — a custom style adds the instructions in its style file to the system prompt), and any key rulesync does not model. That last case matters because `qwencode.tools`, `qwencode.security`, `qwencode.skills` and `qwencode.general` are open objects, so a key Qwen Code adds works before rulesync knows about it — including potent ones such as `tools.discoveryCommand`, which Qwen Code spawns at startup — and rulesync names it rather than writing it silently. Because those groups are open, the _name_ rulesync reports can be authored too, so a warning quotes the key the same way it quotes the value — serialized, with control characters stripped and long text truncated — and neither can forge a warning line of its own. A project-scoped write of one of these keys is reported as well, naming the key, the value and what that particular key decides — the registry controls are described as registry controls rather than as autonomy ones: Qwen Code honors them in a workspace file, and a plain `rulesync generate` is the likelier path for an override that arrived with a repository you cloned. For the keys rulesync writes, only a write that changes what the file already said is reported, so re-running `generate` says nothing new; the note about a key rulesync refuses to write in project scope is printed whatever the file says, because it is about what was left out.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"*\": \"allow\" } },\n> \"qwencode\": {\n> \"tools\": { \"approvalMode\": \"auto-edit\" },\n> \"security\": { \"folderTrust\": { \"enabled\": true } },\n> \"autoMode\": { \"hints\": { \"allow\": [\"Running tests\"] }, \"classifyAllShell\": true }\n> }\n> }\n> ```\n>\n> **Alias overlap:** Qwen's `Read` is a meta-tool that also covers grep/glob/list, so canonical `grep`/`glob` rules are emitted as their own `Grep(...)`/`Glob(...)` entries but overlap Qwen's `Read` category at runtime; and Qwen folds web search into `web_fetch`, so a canonical `websearch` rule (`WebSearch(...)`) may not correspond to a distinct Qwen tool. `tools.disabled` is a hard whole-tool disable (stronger than `deny`) and is only authorable via the override, not the canonical `deny`.\n\nFor oh-my-pi (`omp`), this generates the `tools.approval` and `bash.patterns` keys of `.omp/config.yml` (project mode) or `~/.omp/agent/config.yml` (global mode, default profile; a named profile or `PI_CODING_AGENT_DIR` is not followed, and the legacy `.omp/settings.json` and a global `config.yaml` are neither read nor written). `tools.approval.<tool>` is a per-tool `allow` / `prompt` / `deny` policy that oh-my-pi honors in every approval mode, so a category's catch-all `*` rule is written there (`ask` becomes `prompt`). `bash`, `read`, `edit`, `write`, `grep` and `glob` keep their names, `websearch` becomes `web_search`, `agent` becomes `task`, and any other category (an oh-my-pi-only tool such as `eval`, best authored under a tool-scoped `omp.permission` block, or an MCP tool, which must use oh-my-pi's final registered name such as `mcp__github_create_issue`; a canonical-style `mcp__<server>__<tool>` name logs a warning) is written verbatim (when `agent` and `task` both appear, the stricter policy wins). `webfetch`, `notebookedit` and the bare `mcp` category have no oh-my-pi tool (or key covering every MCP tool) and are skipped with a warning (oh-my-pi fetches web pages through `read`, which a `webfetch` rule does not restrict). The all-tools `*` category has no key of its own: its `deny` and `ask` rules restrict `bash` commands, and they also raise every other tool's policy (the mapped built-in tools, every key Rulesync writes, and every `tools.approval` key kept from the existing file; an oh-my-pi tool with no key, such as an unset `eval`, still follows `tools.approvalMode`): a catch-all one to its own strictness, a pattern-specific one to at least `prompt`, with a warning. Its `allow` rules are skipped with a warning. oh-my-pi has no per-path matcher, so a non-bash category with pattern-specific rules collapses to one policy (`deny` > `ask` > `allow`, with an implicit `ask` when there is no catch-all) and a warning. `bash` rules become the ordered, first-match-wins `bash.patterns` list of `{ match, approval }` records, ordered `deny`, then `prompt`, then `allow` so the first match agrees with the canonical precedence; a bash catch-all `ask` or `deny` is written there as `match: \"*\"`, while a bash catch-all `allow` is written as `tools.approval.bash: allow`, because by default (without `bash.allowCompoundCommands`) an `allow` pattern never approves a compound command line. oh-my-pi, however, lets a critical command such as `rm -rf /` skip a `prompt` pattern unless `tools.approval.bash` prompts (in the default `yolo` mode it otherwise runs), so when the resolved `bash` rules contain any `ask`, Rulesync writes `tools.approval.bash: prompt` (with a warning unless the bash catch-all is already `ask`) and a bash catch-all `allow` becomes a trailing `match: \"*\"` allow pattern instead; a command that no allow pattern matches, including a compound command line, then prompts in every approval mode. oh-my-pi treats only `*` as a wildcard and collapses whitespace runs before matching, so `?` and `[...]` in a bash `deny` or `ask` pattern widen to `*`, while in an `allow` pattern they are matched literally (both with a warning). The optional `omp.approvalMode` override writes `tools.approvalMode`, the tool tiers oh-my-pi auto-approves before it prompts: `always-ask` (read-only tools), `write` (read-only tools and workspace writes) or `yolo` (every tier, oh-my-pi's default); `--approval-mode`, `--auto-approve` and `--yolo` still override it per run, and `tools.approval` policies apply in every mode. Without the override the key is left as it is in the file, and on import a value oh-my-pi honors (it matches the three names exactly) is read back into the override. The file carries every other oh-my-pi setting, so sibling keys such as `bash.enabled` are kept, `tools.approval` entries for tools Rulesync does not manage (anything other than the mapped built-in tools and the keys it writes, e.g. a hand-written `eval` policy) are kept — so a rule for such a tool that is later removed from `.rulesync/permissions.json` stays in `config.yml` until it is deleted by hand — `bash.patterns` is replaced wholesale (oh-my-pi replaces arrays rather than merging them, so a project `bash.patterns` list also hides the global one in that project), and the file is never deleted. On import, `tools.approval` entries become catch-all rules (`tools.approval.bash` becomes the bash catch-all unless a `*` pattern already sets it, and a `deny` always does; a `*` allow pattern imports as `allow` only behind an `allow` policy or next to `ask` patterns with a `prompt` policy, which is how Rulesync writes it, and as `ask` otherwise, since it never approves a compound command line; a `prompt` policy adds no catch-all next to `ask` patterns) and `bash.patterns` become `bash` rules, with whitespace collapsed and later duplicates of a pattern dropped. See the [oh-my-pi settings docs](https://github.com/can1357/oh-my-pi/blob/main/docs/settings.md) and [approval-mode docs](https://github.com/can1357/oh-my-pi/blob/main/docs/approval-mode.md).\n\nFor Pi Coding Agent, this generates the `defaultTools` array in `.pi/settings.json` (project mode) or `~/.pi/agent/settings.json` (global mode). Pi exposes no allow/ask/deny rule surface, so **no canonical permission category maps onto it** — its one repository-syncable tool gate is `defaultTools`, the list of built-in tools enabled at startup (added in Pi v0.84.2). It is an enable-list rather than an allow/deny rule set, so it is authored through a `pi` override block in `.rulesync/permissions.jsonc` — e.g. `{ \"permission\": {}, \"pi\": { \"defaultTools\": [\"bash\", \"edit\", \"write\"] } }` — and round-trips back into it on import. An empty array is meaningful and emitted as written: upstream reads it as \"start with no built-in tools\" while keeping extension and SDK custom tools. **Scope semantics are the opposite of the union merge most targets use**: a project `defaultTools` array _replaces_ the global array rather than adding to it, so the two scopes are written independently and never combined. CLI flags outrank the setting (`--tools` is a strict allowlist over all tools, `--no-tools` disables everything, `--no-builtin-tools` drops the built-in defaults, `--exclude-tools` filters the result). `settings.json` is a hand-edited file holding many unrelated keys (`theme`, `defaultModel`, `packages`, `sessionDir`, …), so writes go through the shared-config gateway with `defaultTools` as the only owned key — everything else is preserved and the file is never deleted. A config that does not state `defaultTools` leaves the key exactly as you left it. See the [Pi settings reference](https://pi.dev/docs/latest/settings).\n\nFor Warp, this generates the command allow/deny regex lists in Warp's global user `settings.toml` (**global mode only** — Warp has no project-scoped permissions file). Since Warp promoted file-backed execution profiles to Stable (2026-07-28), the surface runtime enforcement actually reads is the `command_allowlist` / `command_denylist` arrays of the `default` record under `[agents.execution_profiles.<id>]`; rulesync merges the lists into that `default` profile **in place** whenever the collection exists, preserving every other profile key and every other profile ID. The legacy `agent_mode_command_execution_allowlist` / `agent_mode_command_execution_denylist` keys under `[agents.profiles]` are still written for un-migrated installs and old clients — but on a migrated install they are inert (Warp consumes them only once during its one-shot migration). When the `[agents.execution_profiles]` collection does not exist yet, rulesync deliberately does **not** create it: on such an un-migrated install the legacy keys are still live, and creating the collection would mark Warp's migration complete early and strand the user's other legacy settings. Note that rulesync manages only the `default` profile — if a different execution profile is active in Warp, the generated lists (including `deny` rules) are not enforced until the user switches back to `default`. The settings file path differs per platform: macOS `~/.warp/settings.toml`, Linux `~/.config/warp-terminal/settings.toml`, Windows `%LOCALAPPDATA%\\warp\\Warp\\config\\settings.toml`. The `bash` category maps (`allow` → allowlist, `deny` → denylist); Warp matches commands with **regular expressions**, so patterns are emitted verbatim — author canonical `bash` patterns as regexes when targeting Warp (mirrors Zed). Warp has no per-command `ask` list, so `ask` rules write nothing — but an `ask` still withholds every `allow` that names any of the same commands (an `ask` on `npm *` withholds an allowed `npm publish`, an `ask` on `npm publish` withholds an allowed `npm *`, and an `ask` on `* --force` withholds an allowed `git *` even though neither pattern covers the other's spelling), since canonically the stricter rule wins whatever its width. A `bash` `ask` that withheld no `allow` is not reported, since Warp prompts for whatever its allowlist does not cover and the rule is therefore honored as written. An `ask` written under `*` is reported in that case, on the same terms as a `*` deny: Warp has no ask list to write it to, so withholding is the only way it restricts, and a pattern there need not name a command — `.*--force` is read as a glob needing a literal leading `.` and overlaps none. The all-tools `*` category is read for its **restricting** rules only, and those never reach the denylist: writing any denylist replaces Warp's built-in default one (see below), and a `*` pattern need not name a command at all, so an inert `secrets/**` entry there would trade real protection for a rule that matches nothing. A `deny` or `ask` under `*` therefore withholds the `allow` rules it covers instead — restricting in the same direction without touching the denylist — and is reported in a warning; author the rule under `bash` as a regex to have it enforced as a command. Its `allow` rules are never carried over either, since a pattern under `*` need not be a command — the skip is named in a warning rather than made silently, and both directions fail closed. Note that this read is one-way: import writes patterns back into the `bash` category only, never into `*`. Because Warp's patterns are regexes while the overlap test compares globs, each `bash` pattern is first rewritten into the widest glob it could stand for: a class (`[rf]`), a group (`(sudo )?`), a character escape (`\\s`), a code-point escape together with its digits (`\\x20`, `\\u0020`, `\\U00000067`, `\\x{263A}`), a Unicode-class escape together with its class (`\\pL`, `\\p{Greek}`, `\\PL`), a top-level `|`, a quantifier together with the atom in front of it (`commits?` covers `git commit`, `ab*c` covers `ac`), and a missing `^`/`$` anchor all become `*`. A construct rulesync cannot read as one sequence makes the **whole** pattern `*` — a class, group or quantifier that never closes, a `{` that spells no repetition (`{a|b}` is an alternation in braces, not a quantifier), or a class holding a nested `[` (Rust's regex crate, which Warp matches with, builds one class out of `[a[b]c]`) — since guessing at the rest could only narrow the result. The rewrite only ever widens, so an inexact reading withholds an allow rather than writing one the config restricts. Note what an unanchored pattern means here: `secret .*` matches any command that merely _contains_ `secret `, so it overlaps an unanchored `git .*` and withholds it — anchor both ends (`^git .*$`, `^secret .*$`) when an `ask` on one command should leave the rest auto-approved. Working out which `allow` rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds **every** `allow` beside a restriction — the fail-closed direction — and says so in a warning. A pattern written under the all-tools `*` category is deliberately **not** rewritten: it is canonical, read by every tool, so it is a glob already and is compared as one (`secrets/**` there is a path, not a regex that matches every command mentioning `secrets`). The consequence is worth spelling out: a **regex** written under `*` is not understood as one here, so `.*--force` there is read as a glob needing a literal leading `.` and overlaps no command — author a Warp command rule under `bash`, where it is read as the regex you wrote. Categories other than `bash` and `*` are skipped (with a warning when they carry `deny` or `ask` rules). Writing a `command_denylist` at all **replaces** Warp's built-in default denylist — which covers `rm`, `curl`, `wget`, `eval`, `ssh`, shells, and other risky command patterns — so rulesync warns whenever it emits a non-empty denylist; add canonical `deny` rules equivalent to the built-in patterns you want to keep (see the [Warp CLI permissions docs](https://docs.warp.dev/cli/permissions-and-profiles/)). On import, the `default` execution profile's lists are preferred (falling back to the legacy keys when no collection exists), and a pattern present in both lists resolves to `deny` (Warp's denylist wins). Both blocks are merged into the existing `settings.toml`, preserving other Warp settings, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a settings file holding no command list would say nothing (an existing one is still rewritten). **rulesync owns the command lists** (it is the source of truth): they are replaced from the rulesync config on each `--global` generate, so a manually curated Warp allowlist/denylist not mirrored in `.rulesync/permissions.jsonc` is overwritten — keep command permissions in rulesync (run `rulesync import` first to capture an existing hand-curated list). MCP allow/deny is a separate Warp surface not modeled here. See the [Warp agent profiles & permissions docs](https://docs.warp.dev/agent-platform/capabilities/agent-profiles-permissions/).\n\n> **Warp-only override (`warp` key):** Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy knobs that do not fit the per-command `allow`/`ask`/`deny` model — `agent_mode_coding_permissions` (`always_ask_before_reading` / `always_allow_reading` / `allow_reading_specific_files`), `agent_mode_coding_file_read_allowlist` (an array of paths the agent may read), and `agent_mode_execute_readonly_commands` (a boolean auto-executing read-only commands). Add a tool-scoped `warp` override to author them: its keys are merged into `[agents.profiles]` (the override wins) while the shared `permission` block keeps driving the command lists. On **import**, these keys are lifted from `settings.toml` into the `warp` override, so they round-trip faithfully instead of being dropped. These legacy autonomy keys are part of Warp's one-shot migration, so on a migrated install they are inert; their execution-profile counterparts are authored through the nested `warp.execution_profile` block instead — `read_files` / `apply_code_diffs` / `execute_commands` / `mcp_permissions` (each `agent_decides` / `always_allow` / `always_ask`), `write_to_pty` (`always_allow` / `always_ask` / `ask_on_first_write`), `ask_user_question` (`never` / `ask_except_in_auto_approve` / `always_ask`), `run_agents` (`never_allow` / `always_allow` / `always_ask`), `computer_use` (`never` / `always_ask` / `always_allow`), `directory_allowlist` (paths readable without approval), and `mcp_allowlist` / `mcp_denylist` (MCP server IDs). Its keys are merged into the `default` record of `[agents.execution_profiles.<id>]` under the same guard as the command lists (only when the collection already exists — creating it would complete Warp's migration early; a warning is logged and the block skipped on an un-migrated install), unknown keys pass through verbatim for forward compatibility (export-only: import lifts back exactly the permission keys listed above, while profile-management keys such as `name` or the model overrides never round-trip), and the rulesync-owned `command_allowlist`/`command_denylist` always win. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git .*\": \"allow\" } },\n> \"warp\": {\n> \"agent_mode_coding_permissions\": \"always_allow_reading\",\n> \"agent_mode_execute_readonly_commands\": true,\n> \"execution_profile\": {\n> \"read_files\": \"always_allow\",\n> \"directory_allowlist\": [\"/home/me/projects\"],\n> \"mcp_denylist\": [\"untrusted-server\"]\n> },\n> \"auto_approve_bypasses_command_denylist\": false\n> }\n> }\n> ```\n>\n> **Auto-approve and the denylist:** with auto-approve on, Warp also runs commands that match your own `command_denylist`, because `auto_approve_bypasses_command_denylist` defaults to `true`. Only team denylist rules from the Admin Panel are never bypassed. Set `warp.auto_approve_bypasses_command_denylist` (a boolean) to control it. It is not an execution-profile field, so rulesync writes it to the sibling `[agents.warp_agent.other]` table of the same `settings.toml`, keeping the other keys of that table. It is written on un-migrated installs too, because it is not part of Warp's execution-profile migration. On import it is lifted back into the `warp` override. While the bypass is on (the key is unset or `true`), the denylist-replacement warning also says that auto-approve bypasses the generated denylist. See the [Warp Agent CLI permissions docs](https://docs.warp.dev/agents/cli/permissions-and-profiles/).\n>\n> See the [Warp settings reference](https://docs.warp.dev/terminal/settings/all-settings/).\n\nFor the standalone Warp Agent CLI (target `warpcli`), this generates the command allow/deny regex lists in the CLI's own `settings.toml` (**global mode only**), separate from the Warp app's file: macOS `~/.warp_cli/settings.toml`, Linux `~/.config/warp-terminal/cli/settings.toml`, Windows `%LOCALAPPDATA%\\warp\\Warp\\config\\cli\\settings.toml`. The CLI always runs with the reserved `default` execution profile, so rulesync writes only the `command_allowlist` / `command_denylist` of `[agents.execution_profiles.default]`, converted exactly as for the `warp` target above (including the warning that writing a denylist replaces the built-in one). Unlike the app, the CLI never read the legacy `[agents.profiles]` keys and has no settings migration, so no legacy keys are written and the `default` record is created when absent. The CLI's other execution-profile knobs (`read_files`, `apply_code_diffs`, `run_agents`, …, the same keys as `warp.execution_profile`) are authored through a tool-scoped `warpcli.execution_profile` block, merged into the `default` record before the rulesync-owned command lists, and lifted back into it on import. `warpcli.auto_approve_bypasses_command_denylist` (a boolean) works as described for `warp` above: it is written to `[agents.warp_agent.other]` of the CLI's `settings.toml` and lifted back on import. Set it to `false` to keep the generated denylist in force under auto-approve. Every other key of the file is preserved, the file is never deleted, and it is not created when neither a command rule nor a `warpcli` override key maps (an override alone does create it). See the [Warp Agent CLI permissions docs](https://docs.warp.dev/agents/cli/permissions-and-profiles/).\n\nFor deepagents-cli (dcode), this generates the `[shell].allow_list` array of the user config `~/.deepagents/config.toml` (**global mode only** — dcode reads no project-level config file). dcode auto-approves a shell command when the **executable name** of every segment of its pipeline is in that list and asks about everything else, matching the first token exactly (no globs) after rejecting command substitution, redirects, and process substitution outright. Canonical `bash` rules are therefore reduced to their executable: `git *`, `git:*`, `git commit:*`, and a bare `git` all emit `git` — every spelling but the first two widens the rule, since dcode holds no arguments to narrow it by, so they are written with a warning. The wildcard pattern `*` (or its `*:*` and `* *` spellings) becomes the sentinel `allow_list = [\"all\"]`, which auto-approves every command **and** skips that dangerous-pattern check, so it warns too, and is emitted as the sole entry since dcode rejects the option outright when `all` shares the list. When the same config **restricts** anything — a `deny` or an `ask`, for commands or for another tool — `all` is not written at all: turning the dangerous-pattern check off and approving every command unseen, in the name of a rule meant to restrict, would leave you with less than dcode's own default, so the stricter rule wins and the `*` allow is dropped with a warning. A pattern whose executable still holds a glob (`npm-*`), a shell metacharacter (`git;rm`, `$(id)`), a quote (`\"git\"`) or an escape (`\\git`), or is longer than the 255 characters any file name can hold, is skipped with a warning — dcode compares the name exactly, so a glob matches nothing, and a name holding a metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written, as is one that reduces to a sentinel name (`all`, `recommended` — use `*` for \"every command\"). `ask` needs no output of its own, since a command outside the list already prompts; `deny` has no counterpart at all — dcode asks about an unlisted command rather than blocking it — so a deny rule with no allow entry to hold back is reported as skipped. The all-tools `*` category restricts commands too, so its `deny` and `ask` rules are read alongside the `bash` ones (its `allow` rules never are — a pattern under `*` need not be a command at all — so both directions fail closed, and the skipped allow rules are named in a warning): a `*` deny of `rm *` beside a `bash` allow of `rm *` withholds that allow rather than quietly auto-approving `rm`. Because an entry auto-approves its executable however it is invoked, an `ask` or `deny` on any command that entry would run collides with it — a narrower pattern (`npm publish`) against an allowed `npm *`, and equally a pattern naming no executable at all (`*delete*`, `*--privileged*`) against an allowed `kubectl` or `docker`, since the entry runs those invocations unasked — and dcode has no denylist to settle that collision in — so the colliding `allow` entries are **withheld** from `allow_list` instead: keeping them would auto-approve the very commands the author wanted stopped, while dropping them leaves those executables prompting, which is what an `ask` asks for and the closest dcode comes to a `deny`. Only the colliding entries go; allow rules the restriction says nothing about stay. That covers a narrower rule (`npm publish` or `npm* publish` against `npm *`) and a broader one alike (`*` against every allow, `npm*` against every allowed name it matches), because canonically the stricter rule wins whatever its width — Rulesync collapses colliding rules as deny > ask > allow, and Claude Code applies deny first, then ask, then allow. A quoted or escaped executable (`\"git\" push`, `\\git push`) is read the way dcode reads it — without them — so an odd spelling does not hide the collision. The rule that withheld them is named in a warning, so a deny rule narrower than you meant is visible as the reason a command started prompting. Matching each restriction against what every entry approves is bounded work as well, so a configuration large enough to exhaust that budget treats every allowed executable as colliding and withholds them all — the same fail-closed direction, and the warning says the limit was reached rather than naming a collision nothing compared. On **import**, entries come back as `bash` allow rules named by the executable — an entry dcode could not match in the first place (`git status`, `npm-*`, anything holding a shell metacharacter, a quote or an escape such as `git;rm`, `$(id)` or `\\git`, which dcode splits, rejects or unquotes before it ever compares a name, or a name longer than 255 characters) is skipped with a warning rather than recorded as a permission the tool is not applying, and sentinels are recognized case-insensitively because upstream lowercases them — `all` as `*`, and a list mixing `all` with command names as nothing, since upstream raises on that combination and ignores the option, so those commands are not actually auto-approved. An `allow_list` holding any non-string element, or one that is not a list or a string at all, imports as nothing too, with a warning: dcode requires every element to be a string and drops the whole option otherwise. A `recommended` entry is dropped rather than expanded, with a warning of its own, because the curated list behind it is upstream's to edit — the next generate therefore drops those commands from the allowlist, which fails closed, so re-add by name the ones you want. The `all`-plus-names case warns too, since dcode ignores the whole option there and none of those commands is actually auto-approved. `config.toml` holds every dcode setting, so the `[shell]`, `[startup]`, `[extensions]` and `[mcp]` tables are merged in place and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a file holding no setting would say nothing (an existing one is still rewritten); the merge is a parse and a re-emit, which keeps every unrelated key and table but **does not preserve comments**. A `shell`, `startup`, `extensions` or `mcp` that is not a table at all is left exactly as you have it, with a warning, rather than replaced. See the [deepagents-cli configuration docs](https://docs.langchain.com/oss/deepagents/code/configuration).\n\n> **deepagents-only override (`deepagents` key):** dcode's approval mode is a separate axis from the allowlist and has no canonical per-command slot, so it is authored through a tool-scoped `deepagents` override whose `startup` block is merged into `[startup]` verbatim (unknown keys pass through for forward compatibility): `mode` (`manual` / `auto` / `yolo`, the approval mode a bare launch starts in), `yolo_switcher` (whether YOLO stays in the Shift+Tab mode cycle), and `read_project_dotenv` (whether an untrusted repository's `.env` is loaded into the process environment). On **import**, exactly those three keys are lifted back into the override, and only when the value is one dcode itself would accept (a known `mode`, a real boolean for the switches) — anything else is left behind with a warning, because dcode falls back to its own default for it and writing it would produce a permissions file the next generate could not parse. `startup.recent` is deliberately not lifted at all, since dcode rewrites it as the user cycles modes and committing it would publish one session's state. Generate drops `recent` for the same reason and one more: with no explicit `mode` beside it, that key is what restores auto-approval at launch, so writing it would be a second, quieter way for a repository to change a machine's approval mode. Because this block is written into your **global** config from a `.rulesync/permissions.jsonc` a repository can carry, a value that relaxes what dcode does on its own — `mode` of `auto` or `yolo`, or a `yolo_switcher`/`read_project_dotenv` that was explicitly `false` being turned back on — is warned about by name, alongside the value it replaces (both booleans default to `true` upstream, so writing `true` over an unset key grants nothing and says nothing), and a key rulesync does not know is named too rather than passed through silently.\n>\n> The same override carries an `extensions` block, merged into `[extensions]` the same way. dcode auto-loads `*.py` from your `~/.deepagents/extensions/` **and** from a checked-out project's `.deepagents/extensions/`, so two keys decide whether repository-authored Python is imported into the agent process: `enabled` (the switch above the whole extension system, `true` upstream) and `trust` (`ask` / `always` / `never`, the fallback applied to project extension code when no per-project grant exists, `ask` upstream). On **import**, exactly those two keys come back, with `trust` read the way upstream's `parse_trust_policy` reads it — trimmed and lowercased, so `\" Always \"` is lifted as `always` rather than dropped — and a value dcode itself would not read is left behind with a warning, as in the `startup` block. `extensions.extra_paths` is not modeled: it names machine-local files and directories, which do not belong in a committed permissions file, and it only ever widens what loads — it is not lifted on import, though a hand-written one still passes through on generate like any other unknown key, named in a warning. Because this block too lands in your global config, `trust` of `always`, or an `enabled` that was explicitly `false` being turned back on, is warned about by name alongside the value it replaces. Example:\n>\n> The override's `mcp` block is merged into `[mcp]` the same way and carries one modeled key, `disabled_project_servers`: the names of project MCP servers — the ones a project's `.mcp.json` / `.deepagents/.mcp.json` declares, which Rulesync's `mcp` feature writes — that dcode **always rejects**, a rejection winning over any approval or trust. dcode reads this list from the user-level `config.toml` only, never from a project file, which is why it is authored here rather than beside the MCP config. It is a canonical-shaped `string[]`; on **import** it comes back as the array dcode resolves the value to, so a comma-separated string dcode also accepts (`\"a, b\"`) is lifted as `[\"a\", \"b\"]`, names are trimmed, empty ones dropped, and a non-string element of a list is dropped while the names around it survive — a value that is neither a string nor a list is left behind with a warning, as in the other blocks. The list is written as a whole, so a shorter list from a repository's permissions file would lift a rejection the machine had in place: every name it removes from the existing `[mcp].disabled_project_servers` is warned about by name. `enabled_project_server_approvals` — dcode's own store of the project servers you approved at the prompt, each bound to a repository or worktree and a definition fingerprint — is never lifted on import and is dropped on generate, so a repository cannot pre-approve its own servers through a committed permissions file; `disabled_servers` (the server viewer's toggle) and the deprecated, ignored `enabled_project_servers` are not modeled and pass through like any other unknown key, named in a warning. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git *\": \"allow\" } },\n> \"deepagents\": {\n> \"startup\": { \"mode\": \"auto\", \"yolo_switcher\": false },\n> \"extensions\": { \"enabled\": true, \"trust\": \"never\" },\n> \"mcp\": { \"disabled_project_servers\": [\"filesystem\"] }\n> }\n> }\n> ```\n\nFor the Antigravity IDE, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the committable workspace `.antigravity/settings.json` (**project mode only**). Antigravity 2.0 evaluates these `Deny > Ask > Allow` and uses `action(target)` entries; rulesync maps canonical categories onto the IDE action vocabulary: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the IDE-only `execute_url` / `unsandboxed` actions have no canonical equivalent and pass through verbatim). A `bash` → `command` pattern is translated, because Antigravity does not read a `command` target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a `regex:` target holds one regex per word, each anchored to its word, so `.*` never spans a space. Literal words become a plain target (`npm run *` → `command(npm run)`). An exact command cannot be expressed: `git status` becomes `command(git status)`, which also matches `git status --short`, and an `allow` or `ask` warns about it. A glob inside a word becomes a per-word regex (`npm run test:*` → `command(regex:^npm$ ^run$ ^test:.*$)`), and each further `*` word needs one word (`docker * *` → `command(regex:^docker$ ^.*$ ^.*$)`). A `*` with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so `git push * --force` and `rm *.env` (which also matches `rm -f foo.env`) cannot be translated. The same goes for a bracket that lists a space, for a `?` or negated bracket that can match one anywhere but at the very end (`git?status` also matches `git status`), and for `{a,b}` alternatives. Such an `allow` or `ask` is skipped with a warning (a skipped `ask` runs without a prompt when Antigravity is set to always proceed). Such a `deny` fails closed instead: it is widened to its leading literal words with a warning (`git push * --force` → `command(git push)`, `rm *.env` → `command(rm)`), so it also blocks commands it was not meant to. Antigravity evaluates `Deny > Ask > Allow`, so an overlapping `allow` such as `git push *` → `command(git push)` cannot let `git push origin main --force` run. Only a deny with no leading literal word (`* --force`, `git?status`) is still skipped, and its warning says it is not enforced. A pattern already written as `regex:...` passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (`regex:^ls( -la)?$`), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000, and refuses a class escape at the end of a bracket range (`[a-\\d]`). Rulesync checks this conservatively and skips such a rule with a warning. Other tools do not read a `regex:` pattern, so put it in the tool-scoped `antigravity-ide.permission` / `antigravity-cli.permission` block of `.rulesync/permissions.jsonc` rather than the shared `permission` block, which every target receives. Import reverses this: a plain target `X` reads as `X *` (or, when it holds `*`, `?`, `[`, `{` or `\\`, as an escaped regex, since a plain target is literal), and a per-word regex that only uses `.*` and escapes reads as its glob. Import warns when the result may differ from what Antigravity enforces: a `regex:` word without `^...$` anchors is read as anchored, so the imported glob may be narrower if Antigravity matches it unanchored, and a plain target holding those glob characters (most likely the verbatim output of an older Rulesync) becomes a literal-match regex that no other tool enforces. File and URL targets are not globs either. A `read_file` / `write_file` target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so `src/**` → `read_file(src)`, the workspace root (`./`, `./**`) → `read_file(.)`, a plain path is kept, and `*` / `**` become the bare action. A path that is `~` or starts with `~/` or a `$NAME` variable, or holds a `${NAME}` reference, is kept as written but warned about, because Antigravity documents only workspace-relative and absolute paths and may not expand it, so a deny written that way may block nothing. A `read_url` target is a domain and covers its subdomains, so a URL or `domain:` pattern becomes its hostname (`https://example.com/*` → `read_url(example.com)`) and `https://*` becomes the bare `read_url`; because the URL path is ignored and subdomains are covered, an `allow` or `ask` warns that it covers the whole domain and its subdomains (`*.example.com` included, since the target also covers `example.com` itself). Any other glob (`**/*.env`, `src/*`) has no Antigravity spelling, so the rule is skipped with a warning — for a `deny`, the warning says it is not enforced. These targets import verbatim. Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` file holds other workspace settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. The User-scope settings file is a platform-dependent VS-Code-style path outside rulesync's home-relative global model, so **global mode is not supported**; the workspace file is intended to be checked into git. See the [Antigravity permissions docs](https://antigravity.google/docs/permissions).\n\nFor the Antigravity CLI (`agy`), this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the global `~/.gemini/antigravity-cli/settings.json` (**global mode only**). The CLI shares Antigravity 2.0's Fine-Grained Permissions Engine with the IDE, so the same `action(target)` vocabulary and `Deny > Ask > Allow` precedence apply: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the engine-only `execute_url` / `unsandboxed` actions pass through verbatim). A `bash` → `command` pattern is translated, because Antigravity does not read a `command` target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a `regex:` target holds one regex per word, each anchored to its word, so `.*` never spans a space. Literal words become a plain target (`npm run *` → `command(npm run)`). An exact command cannot be expressed: `git status` becomes `command(git status)`, which also matches `git status --short`, and an `allow` or `ask` warns about it. A glob inside a word becomes a per-word regex (`npm run test:*` → `command(regex:^npm$ ^run$ ^test:.*$)`), and each further `*` word needs one word (`docker * *` → `command(regex:^docker$ ^.*$ ^.*$)`). A `*` with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so `git push * --force` and `rm *.env` (which also matches `rm -f foo.env`) cannot be translated. The same goes for a bracket that lists a space, for a `?` or negated bracket that can match one anywhere but at the very end (`git?status` also matches `git status`), and for `{a,b}` alternatives. Such an `allow` or `ask` is skipped with a warning (a skipped `ask` runs without a prompt when Antigravity is set to always proceed). Such a `deny` fails closed instead: it is widened to its leading literal words with a warning (`git push * --force` → `command(git push)`, `rm *.env` → `command(rm)`), so it also blocks commands it was not meant to. Antigravity evaluates `Deny > Ask > Allow`, so an overlapping `allow` such as `git push *` → `command(git push)` cannot let `git push origin main --force` run. Only a deny with no leading literal word (`* --force`, `git?status`) is still skipped, and its warning says it is not enforced. A pattern already written as `regex:...` passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (`regex:^ls( -la)?$`), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000, and refuses a class escape at the end of a bracket range (`[a-\\d]`). Rulesync checks this conservatively and skips such a rule with a warning. Other tools do not read a `regex:` pattern, so put it in the tool-scoped `antigravity-ide.permission` / `antigravity-cli.permission` block of `.rulesync/permissions.jsonc` rather than the shared `permission` block, which every target receives. Import reverses this: a plain target `X` reads as `X *` (or, when it holds `*`, `?`, `[`, `{` or `\\`, as an escaped regex, since a plain target is literal), and a per-word regex that only uses `.*` and escapes reads as its glob. Import warns when the result may differ from what Antigravity enforces: a `regex:` word without `^...$` anchors is read as anchored, so the imported glob may be narrower if Antigravity matches it unanchored, and a plain target holding those glob characters (most likely the verbatim output of an older Rulesync) becomes a literal-match regex that no other tool enforces. File and URL targets are not globs either. A `read_file` / `write_file` target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so `src/**` → `read_file(src)`, the workspace root (`./`, `./**`) → `read_file(.)`, a plain path is kept, and `*` / `**` become the bare action. A path that is `~` or starts with `~/` or a `$NAME` variable, or holds a `${NAME}` reference, is kept as written but warned about, because Antigravity documents only workspace-relative and absolute paths and may not expand it, so a deny written that way may block nothing. A `read_url` target is a domain and covers its subdomains, so a URL or `domain:` pattern becomes its hostname (`https://example.com/*` → `read_url(example.com)`) and `https://*` becomes the bare `read_url`; because the URL path is ignored and subdomains are covered, an `allow` or `ask` warns that it covers the whole domain and its subdomains (`*.example.com` included, since the target also covers `example.com` itself). Any other glob (`**/*.env`, `src/*`) has no Antigravity spelling, so the rule is skipped with a warning — for a `deny`, the warning says it is not enforced. These targets import verbatim. Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` holds other CLI settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. Five CLI-only autonomy/sandbox knobs outside the allow/ask/deny arrays can be authored (and round-trip) through an optional `antigravity-cli` override block in `.rulesync/permissions.jsonc`: `toolPermission` (the global autonomy preset — `request-review` (default) / `proceed-in-sandbox` / `always-proceed` / `strict`), `enableTerminalSandbox` (a boolean confining agent-run commands to OS containment), `artifactReviewPolicy` (whether the agent's artifact changes are gated on a review prompt — `asks-for-review` (default) / `agent-decides` / `always-proceed`), `allowNonWorkspaceAccess` (a boolean, off by default, letting the agent read or write files outside the active workspace roots), and `agentMode` (the baseline execution mode a session starts in — `default` / `accept-edits` / `plan`). Antigravity applies the allow/deny lists as per-rule exceptions to the preset at runtime, so rulesync authors these keys verbatim as top-level siblings of `permissions` with no precedence modeling. This override is **CLI-only** — the Antigravity IDE exposes the same concepts through a GUI with no documented JSON schema, so it does not apply to `antigravity-ide`. Example: `{ \"permission\": { … }, \"antigravity-cli\": { \"toolPermission\": \"strict\", \"enableTerminalSandbox\": true, \"artifactReviewPolicy\": \"agent-decides\", \"allowNonWorkspaceAccess\": false, \"agentMode\": \"accept-edits\" } }`. Verified against the [Antigravity CLI reference](https://antigravity.google/docs/cli/reference), [sandbox docs](https://antigravity.google/docs/cli/sandbox), [settings reference](https://antigravity.google/docs/cli/settings) and [execution modes](https://antigravity.google/docs/cli/modes). See the [Antigravity CLI permissions docs](https://antigravity.google/docs/cli-permissions).\n\nFor Rovo Dev CLI, this generates the `toolPermissions` block of `config.yml` — the global `~/.rovodev/config.yml`, and in project mode the repo-committed `.rovodev/config.yml` that the [Bitbucket Cloud Agentic Pipelines guide](https://support.atlassian.com/bitbucket-cloud/docs/rovo-dev-advanced-agentic-configuration/) documents (referenced from `bitbucket-pipelines.yml` via `config.path`, or the `--config-file` CLI flag); the project file is deliberately **not** gitignored, since committing it is how Rovo Dev permissions get enforced in CI. Rovo Dev's three levels (`allow`/`ask`/`deny`) are an exact 1:1 with rulesync's canonical actions, so action values pass through verbatim. The `bash` category maps the catch-all `*` pattern to `bash.default` and every other pattern to a `bash.commands[]` entry `{ command: <pattern as regex>, permission }` (Rovo Dev matches commands as regexes, so author `bash` patterns accordingly). The `read` category maps to the inspection tools (`open_files`, `expand_code_chunks`, `expand_folder`, `grep`) and `edit`/`write` to the mutation tools (`find_and_replace_code`, `create_file`, `delete_file`, `move_file`), written under **`toolPermissions.tools`** — the depth Rovo Dev documents. (Earlier Rulesync versions wrote them one level up, directly under `toolPermissions`, where Rovo Dev ignores them; import still reads that legacy shape as a fallback for keys the nested block says nothing about, so an old file is not lost, and a regenerate deletes the stale copies.) Because these per-tool keys hold a single level (no per-pattern rules), only the catch-all `*` of each category sets the level. Rovo Dev rewrites a single tool key when the user answers \"always allow\" to one prompt, so the four keys of a category can disagree; import collapses them back onto one catch-all by taking the strictest level (`deny` > `ask` > `allow`) rather than whichever key is read last. Rovo Dev's planning and Atlassian tools split the same way, so they ride the same two categories rather than getting one of their own: `read` also reaches `getJiraIssue` and `getConfluencePage`, and `edit`/`write` also reach `createJiraIssue`, `updateJiraIssue`, `createConfluencePage`, `updateConfluencePage` and `createTechnicalPlan` (grouped with the mutating tools because it is the planning tool that produces an artifact rather than reading one). Bear that in mind when authoring: an `edit: deny` reaches Jira and Confluence, not just the working tree. Because `edit` and `write` both map onto the same mutation tools, a conflicting catch-all between them cannot be represented; the stricter of the two levels is kept — the same `deny` > `ask` > `allow` rule import uses — and a warning is logged. Non-catch-all `allow` paths in those categories are surfaced as `allowedExternalPaths` so explicit grants are not dropped; non-`allow` non-catch-all rules cannot be expressed per-path and are skipped with a warning. Categories without a clean Rovo Dev target (e.g. `webfetch`) are skipped with a warning. `config.yml` holds all of Rovo Dev's settings (`agent`, `sessions`, `mcp`, etc.), so the `toolPermissions` block is merged in place — every other top-level key is preserved, as is any key inside `toolPermissions` that Rulesync does not manage — including tools inside `toolPermissions.tools` that no canonical category maps to, and the `toolPermissions.bash` sub-keys Rulesync never writes: `env` (the `${VAR}` passthrough that is the documented way to give a CI run a secret whose name matches Rovo Dev's `token|key|password|secret|auth|credential` filter) and `runInSandbox`. Only the `bash.default` and `bash.commands` leaves are rewritten — the `bash` map itself is merged, not replaced. Because those sub-keys now survive every generate rather than being cleared by one, treat them as durable state: the project `config.yml` is committed, so prefer `${VAR}` references over literal credentials in `bash.env`, and note that a `runInSandbox: false` committed to the file stays in force — a regenerate is not a way to reset it, and says so with a warning when it carries one through. On **import**, a tool key the file is silent about counts as the implicit fallback level (`toolPermissions.default`, or Rovo Dev's own `ask`) rather than as absent, and the category still collapses to the strictest of the set. That matters because Rovo Dev writes a single key when the user answers \"always allow\" to one prompt: without the fallback, one such answer about `create_file` would import as a blanket `edit: allow`, and the next generate would hand that grant to every other tool of the category — Jira and Confluence writes included. A category the file says nothing about at all is still skipped rather than invented.\n\n**Migration note.** `toolPermissions.default` and the seven planning/Atlassian keys became Rulesync-owned in the release that added them. Ownership means the first generate after upgrading removes a hand-written value for one of them unless `.rulesync/permissions.*` produces it — a hand-written `tools.createJiraIssue: deny` or `default: deny` with no matching rule in the rulesync source is dropped (with a warning naming each key), falling back to Rovo Dev's `ask`. Run `rulesync import --targets rovodev --features permissions` before the first generate to carry those values into the rulesync source.\n\nThe canonical all-tools category `*` maps to `toolPermissions.default`, the level Rovo Dev falls back to for any tool with no more specific setting (Rovo Dev's own default is `ask`) — derived from its catch-all exactly as `bash.default` is derived from `bash`'s, and round-tripped back on import. A `*` catch-all `deny` also covers shell commands, so it overrides a `bash` catch-all `ask` and `bash.default` becomes `deny` (`deny` > `ask` > `allow`). The default is a single level, so a pattern rule inside the `*` category has no counterpart and is skipped with a warning. The keys Rulesync does manage (`default`, `bash.default`, `bash.commands`, `allowedExternalPaths`, and the per-tool keys above) are owned rather than merged: each generate rewrites them from `.rulesync/permissions.*`, so removing a rule there removes it from `config.yml` too (a source stating no rule at all clears them; one whose rules simply have no Rovo Dev counterpart keeps the block's restrictions but strips its grants — an `allow` there is normally a leftover of an earlier generate, and dropping one falls back to Rovo Dev's stricter default, whereas clearing the whole block would relax every level), logging a warning naming each owned key it removes — per-tool levels and `allowedExternalPaths` are written from inside a Rovo Dev session too, by an \"always allow\" prompt answer and the `/directories` command, and a hand-edit to one of those keys — including a path added with the in-session `/directories` command, which writes to `allowedExternalPaths` — is replaced on the next generate (values only — YAML comments and formatting in the existing file are not retained on rewrite) — and the file is never deleted. See the [Rovo Dev CLI settings](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) and [tool permissions](https://support.atlassian.com/rovo/docs/use-tools-in-rovo-dev-cli/) docs.\n\nFor Goose, this generates the `user` block of the global `~/.config/goose/permission.yaml` (**global mode only** — Goose persists per-tool permission overrides only under the home directory and has no project-scoped permissions file). Goose stores permissions as a YAML map of mode key → `{ always_allow, ask_before, never_allow }`, where each field is a list of tool-name strings; rulesync writes the user-set decisions under the `user` key. Action mapping is a 1:1: `allow` → `always_allow`, `ask` → `ask_before`, `deny` → `never_allow`. Tool-name mapping: `bash` → `shell`; every other category passes through verbatim as the Goose tool name, so `edit` and `write` map onto the Developer extension's `edit` and `write` tools, and other tools like `tree`, `read_image` or an MCP extension's `extension__tool` round-trip. Goose matches these entries against the public tool name exactly, and the built-in Developer extension exposes its tools unprefixed; the pre-v1.27.0 names `developer__shell` / `developer__text_editor` are still accepted on import (`developer__text_editor`, which both edited and wrote files, as both `edit` and `write`) but never written; a literal `shell` category is written as `shell` and imports back as `bash`. The generated names therefore need Goose v1.27.0 or later. Because `edit` and `write` are separate Goose tools, an `edit` rule does not cover `write` (which creates and overwrites files): author both to restrict file changes. When two categories land on the same Goose tool (e.g. `bash` and a literal `shell`), the stricter action wins (`deny` > `ask` > `allow`) and a warning is logged. Because Goose permission lists hold **whole tool names** rather than per-command/per-path globs, only a category's catch-all `*` pattern is representable — non-catch-all patterns are skipped with a warning. The `permission.yaml` file is merged in place: the `user` block is owned by rulesync, while every other top-level key (notably the `smart_approve` LLM-decision cache) is preserved, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a `user` block of three empty lists would say nothing (an existing one is still rewritten). See the [Goose tool permissions docs](https://goose-docs.ai/docs/guides/managing-tools/tool-permissions/).\n\nFor the Grok Build CLI (`grokcli`), this generates Grok's Claude-style `[permission]` rule arrays — `allow` / `deny` / `ask` — in the project `./.grok/config.toml` (project mode) or the user `~/.grok/config.toml` (global mode, via `--global`). Grok documents that \"Project configs are limited to MCP servers, plugins, and permission rules, not full user configs\" ([settings docs](https://docs.x.ai/build/settings)), so the fine-grained `[permission]` rules are valid at both scopes. Each canonical `permission.<category>.<pattern>` becomes a Grok entry bucketed into the matching array: `bash`→`Bash`, `read`→`Read`, `edit`→`Edit`, `grep`→`Grep`, `glob`→`Glob`, `webfetch`→`WebFetch`, `websearch`→`WebSearch`, `agent`→`AgentMessage`, and `mcp__<server>__<tool>`→`MCPTool(<server>__<tool>)`; a `*` pattern emits the bare tool name (e.g. `Bash`) and a concrete pattern emits `Tool(pattern)` (e.g. `Bash(git *)`). `write` collapses onto `Edit` (Grok has no separate `Write` tool — a documented lossy mapping). `Glob` is Grok's accepted alias of the `Grep` filter, so a `glob` rule is enforced exactly like a `grep` rule while keeping its own spelling, and on import `Glob(...)` comes back as `glob` and `Grep(...)` as `grep`. `AgentMessage` (added in Grok 1.0.10) is the nearest named Grok filter to the canonical `agent` category, but it governs a narrower operation: it gates only messages sent to an already-running subagent, not the launch of a subagent (Grok checks a `Task` launch as an `Edit` access on an internal `task:<type>` key, which Rulesync does not target). Its pattern is matched against the _target subagent id_ (as a glob or a prefix) rather than the subagent type a Claude Code `Agent(...)` rule names, so a canonical `agent` rule does not stop Grok from starting subagents — it only controls steering messages to them; write an id-shaped pattern when targeting Grok. Grok's legacy spellings `SendSubagentMessage(...)` / `SendAgentMessage(...)` are read on import and re-emitted as `AgentMessage(...)`. The one category with no Grok tool (`notebookedit`) is skipped, with a warning when it carries a `deny` rule. Grok evaluates the arrays with precedence `deny > ask > allow`, which import mirrors (a tool listed in multiple arrays resolves to the strictest action). The coarse `[ui] permission_mode` toggle (`\"ask\"` / `\"always-approve\"`) is still written as a backward-compatible fallback for older Grok versions: `always-approve` when the config is pure-`allow`, otherwise `ask` (conservative — never `always-approve` while any `deny`/`ask` rule exists, so it never contradicts the fine-grained arrays). Grok's third mode, `auto` (classifier-based, toggled in the TUI with `/auto`), is an exception in both directions: nothing in the canonical model derives it, so Rulesync never writes it — and a `config.toml` that already selects it keeps it, since overwriting would silently downgrade the user to `ask` on every `generate --global`. The fine-grained arrays are still written in that case; only the coarse toggle is left alone. On import, both documented `[permission]` forms are parsed back into canonical categories: the compact `allow`/`deny`/`ask` arrays and the verbose `[[permission.rules]]` tables (`{ action = \"allow\", tool = \"bash\", pattern = \"git *\" }`). The verbose `tool` field is documented lowercase (`any`/`bash`/`edit`/`read`/`grep`/`mcp`/`webfetch`/`websearch`/`agent_message`) while the compact entries are capitalized, so it is matched case-insensitively and `mcp` folds into the canonical `mcp__…` categories exactly as `MCPTool(…)` does; a rule with no `pattern` covers the whole tool. Rules from the two forms merge with the same `deny > ask > allow` precedence, and a rule naming a tool with no canonical category (e.g. `any`) is skipped. The compact arrays also accept Claude Code's `mcp__<server>[__<tool>]` spelling, which import reads like the matching `MCPTool` entry: `mcp__*` → `mcp`, a server-only `mcp__github` → `mcp__github__*`, and `mcp__github__get_issue` → `mcp__github__get_issue`; generate writes them back as `MCPTool(...)`. Only when neither form carries a rule do we fall back to the coarse mode (`always-approve` ⇄ `bash: { \"*\": \"allow\" }`, `ask`/unset ⇄ `bash: { \"*\": \"ask\" }`). Generate always writes the compact arrays. `config.toml` is shared with the MCP feature, so rulesync owns the `[permission]` `allow`/`deny`/`ask` arrays and `[ui] permission_mode` while every other key (e.g. `[mcp_servers]`, `[sandbox]`) is preserved, and the file is never deleted — including a hand-authored verbose `rules` array, which is read on import but left untouched on generate rather than reconciled against the arrays rulesync writes. **Migration:** a `config.toml` written by an earlier Rulesync may carry hand-authored `WebSearch`, `Glob`, `AgentMessage` (or legacy `SendSubagentMessage` / `SendAgentMessage`) or Claude-style `mcp__…` entries that were preserved verbatim as unmanaged; they are now parsed into the canonical `websearch` / `glob` / `agent` / `mcp…` categories and treated as Rulesync-owned on generate, so an entry without a matching canonical rule is dropped rather than kept verbatim. See the [Grok CLI settings reference](https://docs.x.ai/build/settings/reference) and [modes docs](https://docs.x.ai/build/modes-and-commands).\n\nFor Vibe (mistral-vibe), this generates per-tool `[tools.<tool>]` tables in the shared `.vibe/config.toml` (project mode) or `~/.vibe/config.toml` (global mode). Tool-name mapping: `bash` → `bash`, `read` → `read_file`, `edit` → `edit`, `write` → `write_file`, `webfetch` → `web_fetch`, `websearch` → `web_search`, `grep` → `grep`, `agent` → `task`. These are Vibe's builtin tool names (`BaseTool.get_name()`, the snake_case of each tool class); `edit` and `write_file` are distinct tools — `write_file` has been create-only since v2.14.0 — so the two canonical categories no longer collapse onto one name. **Migration:** a `config.toml` written by an earlier Rulesync may still carry `write_file` entries derived from the `edit` category, or inert `[tools.fetch]` / `[tools.search_web]` / `[tools.agent]` blocks. Rulesync only rewrites the names it now emits, so remove those stale entries by hand — a leftover `disabled_tools = [\"write_file\"]` keeps Vibe's `write_file` disabled even though no canonical rule asks for it, and inert `[tools.glob]` / `[tools.notebookedit]` tables an earlier Rulesync emitted for tools Vibe does not have stay on disk until removed by hand (new generates skip those categories instead of rewriting them). Within a category, the catch-all `*` pattern sets the per-tool `permission` (`allow` → `always`, `ask` → `ask`, `deny` → `never`); a wildcard deny additionally adds the tool to the top-level `disabled_tools` filter. A wildcard allow deliberately does **not** touch the top-level `enabled_tools` key: upstream treats it as an **exclusive** allowlist (“if set, only these tools will be active”), so expressing allows through it — as earlier Rulesync versions did — silently switched off every other builtin and MCP tool; the per-tool `permission = \"always\"` entry carries the allow completely, and a regenerate now removes the exclusive entries an earlier version wrote for the tools it configures (`disabled_tools` entries, by contrast, are only cleared by a category that actually states a base permission with a `*` rule — clearing them for a category holding pattern rules alone would silently promote a disabled tool to “enabled, with a denylist”; on **import**, a `disabled_tools` entry likewise wins over a `[tools.<name>]` table that contradicts it — permission and patterns alike are skipped for that tool, because Vibe applies the filter last and unconditionally: reading the table's `permission = “ask”` (or `always` plus an `allowlist`) as the tool's real state would import a hand-written contradiction as a mere prompt and drop the filter on the next generate, and importing one of its patterns as an `allow` would carry a hole through a switched-off tool into every **other** tool's generated config. The match is on Vibe's own tool name rather than the canonical category, since `name_matches` globs the raw name: a bare `disabled_tools = [“read”]` matches no builtin upstream, so it must not silence `[tools.read_file]`. The entry itself is still imported as a deny for its canonical category, because Rulesync cannot see Vibe's registry — an MCP server may well publish a tool named exactly `read` — and denying something that is already off costs nothing where dropping a real deny would. The match follows upstream's `name_matches`, which trims each entry, skips a blank one, and then applies a case-insensitive `fnmatch` glob over the raw name — so `disabled_tools = [\"read_*\"]`, `[\"READ_FILE\"]` and `[\" read_file \"]` all silence `[tools.read_file]`, while `[\"\"]` silences nothing and is dropped rather than imported as an empty category: the canonical `read` category is denied on import, beside the literal `read_*` entry, which is carried out verbatim so the glob keeps its reach over the tools Rulesync cannot enumerate — an MCP tool registered at run time. A `re:`-prefixed entry, which Vibe matches as a Python regular expression, is deliberately **not** evaluated: Python's regex dialect is not JavaScript's, and a wrong verdict either drops a table's rules or carries a disabled tool out as an allow, so such an entry is matched only by its exact spelling and a warning names the tables that were therefore imported as authored; a glob longer than 256 characters is left alone and reported the same way, since a tool name is short in every registry Vibe reads and matching a kilobyte-long entry against a kilobyte-long table name buys nothing. A glob that _is_ resolved is walked step by step against fnmatch's own rules rather than translated into a JavaScript regular expression: the two dialects disagree about exactly the bracket corners (a `]` straight after `[` is a member to fnmatch and an empty class to JavaScript; an inverted range such as `[z-a]` is not dropped upstream but _closed over_, taking the member before it and the member after it with it, so `[z-a]` matches nothing, `[!z-a]` matches everything, `[b-ax]` is just `[x]`, and `[a--!]` collapses to a bare `!` that then negates an empty class and matches everything), and a translated `*a*a*a*b` backtracks catastrophically against a long tool name where the walk stays linear per `*`. The deny patterns of a table the filter switches off are carried across even though its allow patterns are not: dropping them would lose a deny the file states outright, which is the broadening direction. Where a canonical category is shared — `read_file` and a bare `read` MCP table both land on `read`, as do `task` and `agent` — the blanket deny is re-asserted after every table has been read, so the category is denied whichever order the tables happen to sit in; letting the later table decide made the import depend on that order, and in one of the two orders a tool the file had switched off was imported as a live allow. On **generate**, a glob left in `disabled_tools` is for the same reason never deleted to make room for an allow Rulesync just wrote — it reaches tools Rulesync cannot see, and removing it would switch those back on — so the contradiction is reported with a warning instead, since Vibe applies the filter last and unconditionally — the warning names only the globs that actually reach a table Rulesync wrote, and an entry Rulesync does not work out — a `re:` regular expression, or a glob past the 256-character cap — gets a warning of its own asking you to check it by hand rather than being listed beside tables some other glob matched. Both warnings print each name with its control characters stripped and its width capped at 80 terminal columns, and name at most ten before counting the rest, since the text comes from a `config.toml` that may have been checked in by someone else); specific patterns become **`allowlist` / `denylist`** entries — these are the keys Vibe's permission engine actually reads (`BaseToolConfig`), so the legacy `allow` / `deny` keys are dropped on generate from every table a category writes (still honored on import). A table carrying **both** spellings holds one list Vibe enforces and one it ignores, so both are read as a single list and written back under the canonical key: preferring either alone would drop the other, and on the deny side that silently discards a restriction Vibe was applying — multiplied across all three shell tables by the fan-out below. The two sides are not symmetric: reading both deny lists can only restrict further, while reading both allow lists **promotes** a pattern Vibe was ignoring into one it enforces (an `allowlist` match is unconditionally allowed even under `permission = “never”`), so that promotion is announced with a warning naming the patterns — delete them from `config.toml` if the legacy list was stale. Vibe has no per-pattern `ask`, so pattern-level `ask` rules are skipped with a warning. A canonical category with no Vibe builtin tool at all (e.g. `glob`, `notebookedit`) is likewise skipped with a warning instead of emitting an inert `[tools.<category>]` table — a `deny` written there would look applied while Vibe ignores it. The all-tools `*` category is skipped for a different reason: Vibe's config has per-tool tables only, and while `disabled_tools` does match glob patterns (`name_matches`), an entry there removes the tool from the registry outright rather than acting as a default the sibling `[tools.<name>]` tables can override — so `disabled_tools = [\"*\"]` would silently swallow every `allow` authored next to the wildcard. A category that is **not** a canonical Rulesync category is taken at face value as a Vibe tool name and written to `[tools.<name>]`: Vibe's tool manager resolves that table for every registered tool rather than only the builtins, so this is how per-MCP-tool permissions are authored, and `vibe.permission.<name>.sensitive_patterns` works there too. Because Vibe publishes an MCP tool as `<server>_<tool>`, the cross-tool canonical spelling `mcp__<server>__<tool>` is translated to that name on generate (`mcp__github__create_issue` → `[tools.github_create_issue]`; only the first `__` is split, so a tool name may itself contain one). The translation is one-way — Vibe's name carries no `mcp` marker — so import keeps `github_create_issue` as the category; regenerating from it writes the same table. A server-scoped `mcp__<server>` category is skipped with a warning, since Vibe has no server-level permission table; so is a wildcard one (`mcp__<server>__*`, `mcp__*__<tool>`), because a `[tools.<name>]` lookup is exact even though `disabled_tools` entries are glob-matched, which would make `[tools.\"github_*\"]` an inert deny. Since a non-canonical name is written verbatim, a misspelled builtin (`powershel`) becomes an inert table; case mistakes (`Bash`), a mis-cased MCP prefix (`MCP__github__x`) and a glob in the name (`github_*`, whose pattern-level rules Vibe never looks up because `[tools.<name>]` is matched exactly) are the ones detectable without guessing, and those are warned about. The `bash` category is written to three tables — `bash`, `git_bash` and `powershell` — because Vibe's managed shell is a different tool on each platform: the POSIX one publishes `bash`, but on Windows it is `git_bash` or `powershell`, so a `bash` deny landing only on `[tools.bash]` left the shell fully allowed there. Author `git_bash` or `powershell` as its own category in the shared `permission` block to override the fan-out for that shell. A `vibe.permission.<shell>` entry does **not** claim it for the base permission: that block carries `sensitive_patterns` only, so treating it as a claim would strip the `bash` permission from the shell without putting anything in its place — its patterns are merged onto the fanned-out table instead, and a `vibe.permission.<shell>` entry does keep `vibe.permission.bash`'s patterns from overwriting the ones authored for that shell. A shell category that expresses nothing Vibe can read (only pattern-level `ask` rules, or no rules at all) likewise does not claim the shell, since it would otherwise cancel the `bash` deny and write nothing in its place. The fan-out also stands down for a shell the existing `config.toml` already configures differently from `[tools.bash]`, and warns when it does — that is a permission decision made outside the `bash` category, and overwriting it could broaden a `permission = \"never\"` into whatever the canonical `bash` category says. Standing down costs that shell only its **base permission and allow patterns**: the `bash` category's `deny` patterns are still merged into the shell's `denylist` (and its legacy `deny` key folded into the canonical one), because Vibe resolves a denylist match before the allowlist and before the configured permission, so those entries can only restrict the shell further — leaving them out would let a deny you authored go silently missing on one of the three shells. A shell that exists only as a `disabled_tools` entry is skipped there: it is off the registry entirely, so a `denylist` for it would be inert, and writing one would invent a table you never authored. A shell that has its own category in the shared `permission` block is neither stood down from nor reported: that category owns the table outright. The one exception is a `bash` category whose `*` rule is a **deny**: that cannot broaden anything — the shell ends up disabled outright, which is at least as strict as whatever it held — and standing down there would leave an authored deny silently absent from one of the three shells, the exact failure the fan-out exists to prevent. A wildcard deny therefore overwrites the shell (with a warning, which also says that the shell's own `allowlist` / `denylist` entries are replaced along with its permission — the shell ends up disabled outright, so nothing it held is still enforced). Author that shell as its own category to keep a different permission for it. Only the permission keys the fan-out itself mirrors take part in that comparison (`permission`, `allowlist`, `denylist` — with the legacy `allow` / `deny` spellings normalized to the canonical ones — plus `disabled_tools` membership), so a key outside it on `[tools.bash]` — a `timeout`, say, which is carried over for `bash` but never copied to the aliases — does not make Rulesync's own output look hand-authored and freeze the fan-out on the next generate. `sensitive_patterns` is outside it for the same reason: that key is written by the `vibe.permission` pass, which addresses each shell **by name**, so a `vibe.permission.git_bash` entry legitimately leaves that shell holding patterns `[tools.bash]` does not have — counting it would read Rulesync's own output as an outside decision and freeze the fan-out, and mirroring it would overwrite the per-shell patterns you asked for. It is still **filled in** where a shell has none of its own, so a hand-authored `[tools.bash] sensitive_patterns` guard travels with the permission it guards instead of the shells receiving the bare allow. The copy keeps the order it was authored in: `[tools.bash]`'s own list is a key Rulesync does not write, so it is left alone, and sorting only the copy made one guard read as two different lists across the three shells. The fill is one-way — it never clears a shell's patterns — so deleting the `[tools.bash]` guard afterwards leaves the copies behind on the Windows shells; that asymmetry is reported with a warning, because an import would otherwise read them as per-shell rules and write `vibe.permission.<shell>.sensitive_patterns` entries you never authored, which claim those shells out of the fan-out for good. Values are compared as sets, not as written: `denylist = [\"b\", \"a\"]` beside `denylist = [\"a\", \"b\"]` is the same decision, and treating the order as a divergence would strand a `bash` deny on a shell that already agrees with it. A table holding none of those keys — or holding one only as an empty list, which is the absent key spelled out (and which Rulesync drops from its output rather than carrying over) — states no decision and is fanned out over; a key whose value is not a list at all (`denylist = “rm -rf *”`) does count as a decision, even though Vibe's own `BaseToolConfig` types it as `list[str]` and fails to load the file — of the two ways to be wrong about it, deleting the key is the one you cannot recover from (the `bash` deny merge stands down from such a table too, with a warning, since merging into a non-list would replace it rather than add to it — and in that case, as when the `bash` category carries no `deny` patterns at all, the stand-down warning says that **nothing** from the category reaches the shell rather than promising a merge that does not happen); the stand-down warning is likewise only raised when there is a `bash` permission to stand down — when only a `vibe.permission.bash` entry exists, the warning says that its `sensitive_patterns` are what does not reach the shell. The fan-out is also skipped when the `bash` category itself expresses nothing Vibe can read: with no permission to spread, mirroring would push whatever `[tools.bash]` already carries onto shells the file never configured, silently broadening them. When the fan-out does run it **mirrors** `[tools.bash]`'s permission keys onto the two aliases rather than merging each alias with its own previous contents (their unmanaged keys are kept): merging would diverge them the first time `[tools.bash]` carried an entry the aliases lacked — which Rulesync's own output does as soon as a hand-authored `denylist` is merged into `bash` — and that divergence would then stand the fan-out down forever, stranding every later `bash` deny on POSIX. Mirroring is safe precisely because a shell stating a decision of its own has already been excluded, and it makes regenerating idempotent. `disabled_tools` membership is mirrored too, for the same reason. `enabled_tools` membership is deliberately excluded: it is an exclusive registry filter rather than a permission, and counting it let `enabled_tools = [\"powershell\"]` take that shell out of a `bash` deny while leaving it the only active tool. On import, a Windows shell table identical to `bash` is collapsed back into the single `bash` category, while one that differs is kept as its own. Unknown `[tools.*]` tables already on disk still round-trip untouched. The `config.toml` file is shared with the MCP feature, so writes merge non-destructively and the file is never deleted. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/tools/base.py`).\n\n> **Vibe-only override (`vibe` key):** Vibe's `BaseToolConfig` also carries a `sensitive_patterns` list — patterns that escalate to **ASK even when the base permission is ALWAYS** (allow). The canonical model can only set a pattern to a single `allow`/`ask`/`deny`, so an \"allow by default but ask on these patterns\" escalation cannot be expressed in the shared block. Add a tool-scoped `vibe` override to author it: `vibe.permission.<category>.sensitive_patterns` carries the list per canonical category (e.g. `bash`, `edit`), while the shared `permission` block still sets the base permission and allow/deny lists. On import, a tool's `sensitive_patterns` round-trips back into the `vibe` override (the base allow stays in the shared block). rulesync owns the list for any category named in the override (a present list is set, an empty one clears it); categories not named keep whatever the existing `config.toml` had. A `vibe.permission.bash` entry is fanned out to the Windows shells like the base permission is, but where a shell's `config.toml` table already sets **different** patterns of its own it **merges** into them instead of replacing them, and warns that it did: those patterns are what still escalates to ASK once the base permission becomes ALWAYS, so they are the author's only remaining defense there, while dropping the override's patterns would strand a newly added guard on that one shell. A union is safe in both directions because `sensitive_patterns` only ever escalates to ASK. An empty `sensitive_patterns = []` counts as no patterns of the shell's own — it is the absent key spelled out, and Vibe's own config dump writes it that way — so the override's list is applied there in full. The reverse case is the exception to “an empty list clears it”: an empty `vibe.permission.bash.sensitive_patterns` clears the patterns this override owns, but a shell that authored **different** patterns of its own keeps them (with a warning saying so) — delete them from `config.toml` to clear them. Name that shell in `vibe.permission.<shell>.sensitive_patterns` to own its list outright. `sensitive_patterns` is the only key this override can express — a `permission`, `allowlist` or `denylist` written inside `vibe.permission.<category>` is ignored with a warning, since the shared `permission` block is where those belong and the base permission it sets can be the exact opposite of what the ignored key asked for. The override also carries `vibe.enabled_tools` — the only way to author Vibe's top-level **exclusive** allowlist. The list is written verbatim in Vibe's tool-name vocabulary (declaring it, even empty, makes rulesync own the whole key), and on import a non-empty `enabled_tools` is lifted back into the override rather than being misread as a set of `\"*\": \"allow\"` grants. Note the `config.toml` scope semantics: since v2.24.0 Vibe installs the user and project TOML layers **together** rather than picking one, so a trusted project config overlays the user config instead of replacing it, and a `--global` run is no longer discarded wholesale by a project `config.toml` — the project layer still wins key by key where it sets one, but every key it leaves unset falls through to the global file. Merging is per key: `mcp_servers` and `connectors` union-merge by name, `tools` deep-merges, `disabled_tools` concatenates, and `enabled_tools` is replaced wholesale by the higher layer. An org-enforced `AdminConfigLayer` sits above every layer at runtime and can override anything below it. (Whether a project layer is read at all still depends on Vibe trusting the project, so treat the overlay as the trusted-project behavior.)\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"*\": \"allow\" } },\n> \"vibe\": { \"permission\": { \"bash\": { \"sensitive_patterns\": [\"rm *\", \"sudo *\"] } } }\n> }\n> ```\n\nFor Takt, this generates the `default_permission_mode` under `provider_profiles.<provider>` in the shared `.takt/config.yaml` (project mode) or `~/.takt/config.yaml` (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered `readonly` < `edit` < `full` (`readonly` may only read, `edit` may also edit/write files, `full` may also run shell commands). The active provider is resolved from `runtime.yaml` first when Takt is in runtime provider mode (see below) — the provider of the profile named by `provider.defaults.profile`, with no fallback, matching Takt (a lone profile is not promoted to the default, and `defaults.pool`/ladder forms resolve at run time) — and otherwise from the top-level `provider:` key of `config.yaml` (a plain name or the `provider: { type: … }` block form), then — on a project-mode generate whose file names none — the top-level `provider:` of the global `~/.takt/config.yaml`, as Takt itself resolves `provider` local → global → default; failing both, Rulesync falls back to its own heuristic of the sole `provider_profiles` entry, and then to the `claude-sdk` default. Import reads only the file it was pointed at and does not consult the global one, and a generate that inherits from the global file depends on that machine's `~/.takt/config.yaml`, so commit an explicit `provider:` in the project config when the output has to be the same everywhere (for example under `generate --check` in CI). That default follows Takt 0.68.0, which made the Claude Agent SDK the default provider and looks permission profiles up by the selected provider name without resolving the `claude` alias: with no provider configured Takt reads `provider_profiles.claude-sdk`, while an explicit `provider: claude` still reads `provider_profiles.claude`. A lone `claude` profile with no `provider:` key is not promoted by the sole-profile rule, because earlier Rulesync versions wrote it as their own fallback: the mode is written to `claude-sdk` instead, the old `claude` profile is left in place, and import reads the (usually absent) `claude-sdk` profile. On an older Takt with no `provider:` key, set `provider: claude` explicitly so the generated mode lands in the profile that version uses. `provider_profiles` itself stays in `config.yaml` in every version: it is not a legacy provider signal, and Takt does not move permission modes into `runtime.yaml`. The mapping is therefore **lossy**: on generate, a single mode is derived with this precedence — (1) any `deny` rule anywhere ⇒ `readonly` (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any `edit`/`write` category `allow` rule ⇒ `edit`; (3) else any `bash` category `allow` rule ⇒ `full`; (4) else ⇒ `readonly` (safe default). On import, `full` ⇄ `bash: { \"*\": \"allow\" }`, `edit` ⇄ `edit: { \"*\": \"allow\" }`, and `readonly` (or an unset/unknown mode) ⇄ `bash: { \"*\": \"deny\" }`. `config.yaml` is shared with other Takt settings, so the mode is merged in place — every other provider profile and all other top-level keys are preserved — and the file is never deleted. Takt's default-deny **workflow security policies** — `workflow_arpeggio` (`custom_data_source_modules`, `custom_merge_inline_js`, `custom_merge_files`), `workflow_runtime_prepare.custom_scripts`, `workflow_command_gates.custom_scripts`, `sync_conflict_resolver.auto_approve_tools`, and the `allow_git_hooks` / `allow_git_filters` booleans — have no canonical permission category, so they are authored through the `takt` override block of `.rulesync/permissions.*` and round-trip on import. Each admits one class of user-supplied code, so only the exact shapes Takt itself accepts are written: a sub-key Takt does not declare is dropped with a warning rather than passed through, since Takt's schemas are strict and reject the whole file on an unknown key, while a value of the wrong type fails when `.rulesync/permissions.*` is read. Removing one of these keys from `config.yaml` because the source no longer states it is warned about too — including a key put there by hand, which owning them implies. Deleting `.rulesync/permissions.*` altogether is different: the feature has no source to generate from, so nothing runs and whatever is in `config.yaml` stays. These keys are also authoritative rather than merged — revoking one in `.rulesync/permissions.*` removes it from `config.yaml`, instead of leaving the capability switched on. `workflow_mcp_servers` stays with the MCP feature, which derives it from the transports in use.\n\nTwo Takt-specific surfaces with no canonical category can be authored (and round-trip) through an optional `takt` override block in `.rulesync/permissions.jsonc`: `step_permission_overrides` (a per-workflow-step map `<step>` ⇒ `readonly`/`edit`/`full`, written inside the active provider profile and layered by Takt on top of `default_permission_mode`) and `provider_options` (a top-level, per-provider table of sandbox/network knobs orthogonal to the mode, e.g. `codex.network_access`, `claude.sandbox.allow_unsandboxed_commands`, `opencode.allowed_tools`). Example: `{ \"permission\": { … }, \"takt\": { \"step_permission_overrides\": { \"ai_review\": \"readonly\" }, \"provider_options\": { \"codex\": { \"network_access\": true } } } }`. Note the workflow-step `required_permission_mode` floor is a field of the **workflow YAML**, not `config.yaml`, so it is intentionally out of scope (Takt's config loader hard-rejects unknown top-level keys).\n\n**`provider_options` and Takt 0.56.0 (`runtime.yaml`).** From 0.56.0, provider configuration lives in `runtime.yaml` (`.takt/runtime.yaml` for the project, `~/.takt/runtime.yaml` globally), and \"runtime provider mode\" is active as soon as its `provider:` section carries an actual assignment — a non-empty `defaults`, `profiles` or `auto_routing`, a `targets` map with at least one non-empty entry, or a named `assignments` set with a non-empty `defaults` or such a `targets` map. A file holding only `version: 1`, or empty maps such as `defaults: {}`, is inactive and leaves the legacy resolution in place. Companion reviewers are opt-in (`companion.enabled` defaults to `false`), and while they are off Takt evaluates the section without its `targets.companions` maps and without the profiles only those reach through `extends` — so a file that merely routes companions is inactive too, and a profile is ignored (its `options` are not lifted on import) unless an agent assignment also reaches it. While runtime mode is active, **any** legacy provider setting in `config.yaml` — `provider_options` among them — stops Takt with `Mixed provider configuration detected` before it runs an agent, and Takt generates an active `~/.takt/runtime.yaml` on first launch in a fresh environment, so new installs are in runtime mode by default. Rulesync therefore reads `runtime.yaml` — both the scope being generated and the global one, because Takt collects legacy signals from both `config.yaml` files — and merges them the way Takt's loader does before deciding anything: `provider.profiles` and `provider.assignments` are unions in which a project entry replaces the global entry of the same name, while `defaults`, `targets` and `auto_routing` are taken from the project file whole whenever it states them at all, so a project `targets: {}` masks the global one rather than merging with it; `companion.enabled` is on only when neither file turns it off. On that merged document, while runtime mode is active, rulesync **refuses to write `provider_options`** into `config.yaml`, warning instead of quietly emitting a key that would take the install down. Rulesync does not write `runtime.yaml` itself: a profile is provider- **and** scope-specific, and Takt replaces a same-named profile wholesale across scopes rather than merging it field by field, so there is no key rulesync could own there without clobbering the user's provider and model. Author those options yourself under `provider.profiles.<profile>.options` in `runtime.yaml` — a **flat bag applying to that profile's own provider**, so the `codex:` / `claude:` segment of `provider_options` is dropped — and remove `provider_options` from the `takt` block of `.rulesync/permissions.*`. Anything already written into `config.yaml` by hand is left untouched; rulesync does not own that key. On **import**, both sides are read: the legacy `provider_options` table and, in runtime mode, each profile's `options` from the `runtime.yaml` of the scope being imported (import stays inside the tree it was pointed at), re-keyed by the profile's own `provider` (the runtime side wins on a collision), so nothing is lost. One consequence worth knowing: importing a runtime-mode install produces a `takt.provider_options` block that a later generate will refuse and warn about — drop it from the rulesync source once the options are settled in `runtime.yaml`. Installs with no `runtime.yaml`, or an inactive one, keep the pre-0.56.0 behavior unchanged: `provider_options` is written to `config.yaml` exactly as before.\n\nSee the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).\n\nFor Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.config/amp/settings.json` (global mode), using **two** permission surfaces. In rulesync's canonical model the category name **is** the Amp tool name. A **whole-tool deny** (pattern `*`) is written to the bare `amp.tools.disable` array (the tool name is pushed verbatim, preserving `builtin:` prefixes and the `*` glob) for backwards compatibility. Every **lossy** case is written to the ordered `amp.permissions` array instead of being dropped: an **argument-specific deny** (pattern `!== \"*\"`) becomes `{ tool, action: \"reject\", matches: { cmd: <pattern> } }`, and every `allow` / `ask` rule becomes `{ tool, action, matches?: { cmd } }` (the `matches` object is omitted for the `*` catch-all). Amp evaluates `amp.permissions` **first-match-wins**, so generated entries are ordered deterministically and fail-closed: sorted by tool name, then entries **with** `matches.cmd` (more specific) before catch-alls, then by action priority **`reject` < `ask` < `allow`**, then by `cmd`. `amp.permissions` is Amp's **legacy / backwards-compatibility** surface (gone from the web docs, see below) — it remains functional and is the only place to express `allow`/`ask` and argument-specific `reject` rules. **Ownership:** rulesync OWNS and wholesale-replaces the `allow`/`ask`/`reject` entries on every generate, but **preserves any existing `action: \"delegate\"` entry** (rulesync's canonical model has no `delegate` equivalent); preserved `delegate` entries are placed **after** the rulesync-generated entries (so the regenerated rules take precedence under first-match-wins). On **import**, both keys are read and merged into one canonical config: `amp.tools.disable[tool]` → `{ tool: { \"*\": \"deny\" } }`, and each `amp.permissions` entry → `{ tool: { (matches?.cmd ?? \"*\"): mapped } }` (`reject` → `deny`, `allow` → `allow`, `ask` → `ask`; `delegate` is skipped). When both sources target the same tool+pattern, the **most restrictive action wins** (`deny` > `ask` > `allow`). The settings file is shared with the MCP feature (`amp.mcpServers`), so all other keys are preserved on round-trip and the file is never deleted. Tool names and `cmd` patterns that are prototype-pollution keys (`__proto__`, `constructor`, `prototype`) are skipped defensively. The whole settings document is also recursively sanitized on read, by both the permissions and the MCP adapter: every object is rebuilt from its own entries, so a `__proto__` key cannot swap an object's prototype and `constructor` / `prototype` never survive as own keys. A root `__proto__` is therefore stripped and the rest of the file is read normally, rather than failing the \"must be a JSON object\" check as it did before sanitization was added — the surrounding settings are kept, and only the poisoned key disappears.\n\nAmp shapes with no canonical category are authored (and round-trip) through an optional `amp` override block in `.rulesync/permissions.jsonc`: `permissions` — extra `amp.permissions` entries with non-`cmd` matchers (`path`/`url`/`query`/…), regex/array match values, `context` (`thread`/`subagent`), `delegate` (+`to`), or `reject` (+`message`), appended **after** the canonical-generated entries (so generated allow/ask/reject rules take precedence under first-match-wins, with authored entries as later fallbacks); `mcpPermissions` — Amp's `amp.mcpPermissions` array; `guardedFiles` — `amp.guardedFiles.allowlist` (globs allowed without confirmation); and `dangerouslyAllowAll` — `amp.dangerouslyAllowAll`. When the override authors `permissions` it becomes the source of truth for the extra entries; otherwise any hand-authored `delegate` entry in the existing file is preserved. On import, `amp.permissions` entries that are **not** canonical-expressible (non-`cmd` matcher, `delegate`, `reject`+`message`, `context`) are lifted verbatim into `amp.permissions` of the override rather than dropped. Example: `{ \"permission\": { … }, \"amp\": { \"dangerouslyAllowAll\": false, \"guardedFiles\": { \"allowlist\": [\"docs/**\"] }, \"permissions\": [{ \"tool\": \"Bash\", \"action\": \"delegate\", \"to\": \"approve.sh\" }] } }`. Note that the `Permissions` section of Amp's web docs now only points at custom plugins and no longer describes `amp.permissions`, `amp.guardedFiles.allowlist` or `amp.dangerouslyAllowAll`; the shipped CLI still honors them and still lists all three in its built-in settings reference (`amp --help`), alongside the `amp permissions` subcommands. `amp.mcpPermissions` (like `amp.tools.disable`) remains in the web [CLI settings reference](https://ampcode.com/docs/cli/settings). See the [Amp permissions docs](https://ampcode.com/docs/tools#permissions) and the [plugin permissions example](https://ampcode.com/docs/customize/plugins#example-plugin-permissions).\n\nFor JetBrains Junie CLI, this generates the Action Allowlist `rules` object in `~/.junie/allowlist.json` (**global mode only** — Junie CLI resolves exactly one allowlist path under its home directory and never reads a project-scope `.junie/allowlist.json`; verified against release `2383.10`). Junie evaluates the allowlist top-to-bottom (first match wins) and groups rules into buckets, onto which rulesync categories map: `bash` → `executables`, `edit`/`write` → `fileEditing`, `read` → `readOutsideProject`, `mcp` → `mcpTools`. Every rule group is written as Junie's `AllowListRuleSet` **object** — `{ \"default\"?: \"allow\"|\"ask\", \"rules\": [ … ] }` — never a bare array: Junie's parser rejects the array form for the **whole file** and then discards and overwrites `allowlist.json`, so the shape matters. Earlier rulesync versions emitted the array form; it is still tolerated on import, but only the object form is generated. Each rule carries an `action` plus either a literal `prefix` (matches commands that start with it) or a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`); rulesync emits `pattern` when the canonical pattern contains a glob metacharacter (`*`, `?`, `[`) and `prefix` otherwise. Junie accepts only `allow` and `ask` as actions — there is **no `deny`** (a `deny` fails the whole-file parse) — so a canonical `deny` is downgraded to the nearest valid action, `ask` (which still withholds auto-approval), with a warning (`allow`/`ask` map 1:1). Categories Junie cannot represent (e.g. `webfetch`, `websearch`) are skipped with a warning when they carry rules. rulesync **owns each mapped group's rule list** (replaced on each generate), while a per-group `default` and the whole `readSecretFile` group — which restricts what Junie may read — are preserved from the existing file when not authored via the `junie` override below. Because `edit`/`write` both collapse onto `fileEditing`, importing normalizes back to `edit` (a documented, lossy mapping). The `allowlist.json` file is never deleted. See the [Junie Action Allowlist docs](https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html).\n\n> **Junie-only override (`junie` key):** Junie's `allowlist.json` has settings with no canonical per-glob slot — the top-level autonomy knobs `allowReadonlyCommands` (a boolean auto-allowing read-only commands) and `defaultBehavior` (the fallback action when no rule matches; an `allow`/`ask` enum — Junie's `AllowListDecision` accepts nothing else, and an invalid value fails the whole-file parse), plus two group-shaped settings: `readSecretFile` (the fifth rule group, restricting reads of secret files — canonical `read` is already taken by `readOutsideProject`, so this group is authored whole as `{ \"default\"?, \"rules\": [ … ] }`) and `ruleDefaults` (each mapped group's own fallback action, e.g. `{ \"executables\": \"ask\" }`). Add a tool-scoped `junie` override to author them: the scalar knobs are merged onto the top level of `allowlist.json` (the override wins) while the shared `permission` block keeps driving the mapped groups' rule lists, and the group-shaped settings land inside the `rules` object. On **import**, all of these are lifted from `allowlist.json` into the `junie` override, so they are authorable and portable instead of only round-trip-preserved. Any other unmodeled top-level key is preserved verbatim. Example:\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git \": \"allow\" } },\n> \"junie\": {\n> \"allowReadonlyCommands\": true,\n> \"defaultBehavior\": \"ask\",\n> \"ruleDefaults\": { \"executables\": \"ask\" },\n> \"readSecretFile\": { \"rules\": [{ \"pattern\": \"**/.env\", \"action\": \"ask\" }] }\n> }\n> }\n> ```\n\nFor Reasonix, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the `[permissions]` table of the shared `reasonix.toml` (project mode) or `~/.reasonix/config.toml` (global mode) — the same TOML file the MCP feature's `[[plugins]]` array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are `Bash(<pattern>)`, `Read(<pattern>)`, `Edit(<pattern>)`, `Write(<pattern>)`, `WebFetch(<pattern>)`, `WebSearch(<pattern>)`, `Grep(<pattern>)`, `Glob(<pattern>)`, `NotebookEdit(<pattern>)`, `Agent(<pattern>)`, etc. (Reasonix's SPEC.md documents these as \"Claude Code-style\" families; `agent` → `Agent` is the one lower-confidence mapping, since Reasonix's own delegation tool is internally named `task`). `[permissions].mode` (the writer fallback: `ask`/`allow`/`deny`) has no canonical rulesync equivalent: it is preserved untouched unless the `reasonix.mode` override below sets it. Note that Reasonix narrows a project `reasonix.toml` before merging it (`narrowPermissions` in `project_scope.go`, on both the v1 and v2 lines): project `allow`, `mode` and `allow_dynamic_bash` are user-only and are replaced by the user config's values, while project `ask` and `deny` merge additively. A project-mode `allow` entry (and the `allowDynamicBash` override below) therefore only takes effect when the user config (`--global`) carries the same value — or, on v2, when the user has granted it to that workspace in Reasonix's own project-grant store — and Reasonix reports the rest as ignored; see [project_scope.go (v2.31.0)](https://github.com/esengine/DeepSeek-Reasonix/blob/v2.31.0/internal/contract/config/project_scope.go) and [(v1.39.8)](https://github.com/esengine/DeepSeek-Reasonix/blob/v1.39.8/internal/config/project_scope.go). The TOML file is shared with the MCP feature, so writes only replace the `permissions` table — every other table (`[[plugins]]`, `[agent]`, `[ui]`, …) is preserved on round-trip, and the file is never deleted. See [SPEC.md §3.7 Permissions](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md).\n\n> **Reasonix-only override (`reasonix` key):** Reasonix has security axes orthogonal to per-tool allow/ask/deny with no canonical category — the `[sandbox]` enforcement table (`workspace_root`, `allow_write`, `forbid_read`, `bash` = `enforce`/`off`, `network`) and the plan-mode read-only command list under `[agent]` (`plan_mode_read_only_commands`, which upstream keeps for legacy compatibility only — Plan bash goes through Permissions now). Its sibling `plan_mode_allowed_tools` left the documented config surface in v1.17.18: an existing value is still lifted out of `[agent]` on import, so it does not vanish from an imported config, but whenever the override writes `[agent]` the key is removed from the file with a warning — including a value already there, since leaving that one alone would mean narrowing the list is the one edit that never lands. Add a tool-scoped `reasonix` override to author them: `reasonix.sandbox` and `reasonix.agent` are shallow-merged into the matching `reasonix.toml` table at its top level (override keys win, unrelated sibling keys such as `[agent].model` are preserved), while the shared `permission` block keeps driving `[permissions].allow`/`ask`/`deny`. The `[sandbox]` values that loosen the enforcement layer under the permission policy — a `bash` set to anything but `enforce`, a `network` that is not `false`, a non-empty `allow_write`, and a `workspace_root` that is anything but a plain relative path inside the project — an absolute path in POSIX or Windows form, a drive-lettered one such as `C:temp`, a home-relative one, a path holding a `..` segment, or one carrying a `$…` / `%…%` expansion whose target cannot be known here; as with the other keys, a value that is not the type Reasonix documents is reported rather than assumed harmless — are written, but named in the same one-warning-per-file summary rulesync gives Claude Code's and Devin's trust-affecting sandbox paths. Because the merge replaces `forbid_read` whole rather than accumulating it, that summary also names the read protections a generate would drop: a `forbid_read` that loses an entry the file had, one the file holds as something other than a list so what it blocked cannot be read, and a `[sandbox]` the file holds in some shape other than a table, which the write replaces wholesale. A value already in the file that the override does not author is left alone and not reported. The override also carries `rawAllow`/`rawAsk`/`rawDeny` — verbatim `[permissions]` entries merged into the generated arrays untranslated. They exist for the first-class `Bash=<literal>` exact-command form (SPEC §3.7, v1.18.0: metacharacters in the literal are ordinary characters and only the identical complete command matches), which the canonical tool→pattern→action shape cannot express. It is also the pattern-level way to pre-authorize **nested or indirect** Bash — command and process substitution, `eval`, `source`, `sh -c` and the like, which Reasonix gates harder than a merely dynamic command line — in a headless `reasonix run`. The blanket alternative is `reasonix.allowDynamicBash`, which writes `[permissions] allow_dynamic_bash` (added in v1.19.0): where a `rawAllow` entry pre-authorizes one exact literal, this lets an Allow fallback — Auto included — cover that whole class at once, with explicit `ask` and `deny` rules keeping precedence over it. Setting it to `true` is written but named in a warning, as with the other keys that widen what a shareable permissions file lets run without a human; leaving the key out of the override keeps whatever the file already had, and it is lifted back into the override on import when it holds the boolean Reasonix documents. YOLO is a run-time posture rather than config, so there is nothing to author for it. `reasonix.mode` (`ask`, `allow` or `deny`) writes the `[permissions] mode` writer fallback. Reasonix honors it only in the user config and ignores a project value, so it is written only with `--global`; a project-scope generate skips it with a warning and keeps whatever the file had. A global `mode` newly set to `allow` is named in a warning, because it lets every writer call that no rule matches run without a prompt. On import it is lifted back into the override from the user config only, when it holds one of the three documented values. Exact entries already in `reasonix.toml` — Reasonix writes them itself as remembered approvals — are always preserved on generate, even for tools the shared block manages. On import, the whole `[sandbox]` table round-trips (it is a dedicated security surface), only the plan-mode keys are lifted from `[agent]`, and exact `Tool=<literal>` entries are lifted into `rawAllow`/`rawAsk`/`rawDeny` instead of masquerading as a bogus tool category in the shared block.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git status*\": \"allow\" } },\n> \"reasonix\": {\n> \"sandbox\": { \"bash\": \"enforce\", \"network\": false },\n> \"agent\": { \"plan_mode_read_only_commands\": [\"gh pr diff\"] },\n> \"allowDynamicBash\": true\n> }\n> }\n> ```\n>\n> The retired `[[plugins]].trusted_read_only_tools` MCP read-only trust list is per-plugin (an array-of-tables shared with the MCP feature) and is not covered by this override.\n\nFor Devin Local, this generates the `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays of the shared `.devin/config.json` (project mode) or `~/.config/devin/config.json` (global mode) — in global mode the same file the hooks feature patches (MCP moved out to `mcp_config.json` in v3000.3); rulesync writes project-scope Devin hooks to a standalone `.devin/hooks.v1.json` instead, so a project `.devin/config.json` carries permissions alone. Entries are Devin's scope matchers: `read` → `Read(<glob>)`, `write`/`edit` → `Write(<glob>)`, `bash` → `Exec(<prefix>)`, `webfetch` → `Fetch(<pattern>)`, `websearch` → the bare `web_search` tool name (accepted in the permission lists since CLI v3000.10.21), plus `mcp__server__tool` names passed through verbatim. A `*` pattern collapses to the bare scope (`Exec`). `web_search` has no pattern matcher, so pattern-specific `websearch` rules are collapsed to a single action with a warning (`deny` > `ask` > `allow`; a map without `*` also counts an implicit `ask`, so a narrow allowlist never becomes a blanket allow; an empty `websearch` map emits nothing — like any other empty category it still retracts the `web_search` entries rulesync manages, which leaves Devin's auto-approve default in place), and on import `web_search` maps back to `websearch`. A CLI older than v3000.10.21 rejects a `web_search` entry, so upgrade before generating one. Devin evaluates the arrays with `deny` > `ask` > `allow` precedence, so a deny rule always wins; on import `Write` maps back to `write`, so `edit` rules round-trip as `write`. Only the keys rulesync manages are rewritten — `permissions`, plus `sandbox` in global mode when the `devin` override below authors it — and every other key is preserved and the file is never deleted. See the [Devin permissions reference](https://docs.devin.ai/cli/reference/permissions).\n\nFor Continue, permissions are **global-only** and generate the `allow`, `ask` and `exclude` lists of `~/.continue/permissions.yaml`, the tool-policy file of the Continue CLI (`cn`) — Continue reads no project-scoped policy file, so a project-scope run is skipped with a warning and `--global` is required. Each entry is `Tool` or `Tool(pattern)`: the canonical categories map to Continue's built-in tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `webfetch` → `Fetch`), the `*` pattern of a category becomes the bare tool name, the all-tools `*` category becomes a bare `*` (a pattern-specific rule under `*` is skipped with a warning, because Continue would match it against no tool argument; when a `bash` category exists it is mirrored onto `Bash` instead), and any other category is passed through as a tool name (which is how an MCP tool is named). `deny` lands in `exclude`; a pattern that itself contains a parenthesis is skipped with a warning because Continue's parser rejects it, and a rule that appears in more than one list is named in a warning. Entries already in the file that name a tool the canonical block does not mention are kept, and entries Rulesync cannot parse are left where they are; an empty canonical block writes no file. On import the three lists map back to `allow` / `ask` / `deny` (first list wins for a duplicated entry). See the [tool permissions docs](https://docs.continue.dev/cli/tool-permissions).\n\nFor CodeBuddy Code, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.codebuddy/settings.json` (project mode) or `~/.codebuddy/settings.json` (global mode), in Claude Code's rule syntax: `Tool` or `Tool(specifier)`. The canonical categories map to CodeBuddy's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `notebookedit` → `NotebookEdit`, `webfetch` → `WebFetch`, `websearch` → `WebSearch`, `agent` → `Agent`, `skill` → `Skill`), the `*` pattern of a category becomes the bare tool name, and patterns are written as they are, so CodeBuddy's own spellings (`Bash(npm run test:*)`, `Read(//abs/path)`, `WebFetch(domain:example.com)`) work unchanged. Any other category is written as the tool name it is (`mcp__github`, `mcp__github__get_issue`, `TaskCreate`), like the `claudecode` target; an `mcp__` category takes no specifier, so a pattern other than `*` there is skipped with a warning. The bare `mcp` category becomes `mcp__<pattern>` and its `*` pattern the all-MCP wildcard `mcp__*`, which CodeBuddy honors in `deny` and `ask` only, so an `mcp: { \"*\": \"allow\" }` is skipped with a warning. The all-tools `*` category's `*` pattern becomes CodeBuddy's standalone `*` rule (which does not cover MCP tools); a narrower `*` pattern is mirrored onto `Bash` when a `bash` category exists and is otherwise skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins, matching CodeBuddy's `deny` > `ask` > `allow` precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: `defaultMode`, `additionalDirectories`, `trustedDirectories` and the other keys of `permissions` are kept, as is every existing entry for a tool the canonical config does not mention; a list that ends up empty is removed, and the file itself is never deleted. The gitignored `.codebuddy/settings.local.json` layer is left to CodeBuddy. On import each entry maps back to its category and pattern (`mcp__*` to the bare `mcp` category, the standalone `*` to the all-tools `*` category, an empty `Tool()` or unbalanced entry is skipped), with the strictest action winning for an entry listed more than once. See the [permissions docs](https://www.codebuddy.ai/docs/cli/permissions).\n\nFor Command Code, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.commandcode/settings.json` (project mode) or `~/.commandcode/settings.json` (global mode). Each entry is `Tool` or `Tool(pattern)`: the canonical categories map to Command Code's friendly tool names (`bash` → `Shell`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `webfetch` → `WebFetch`, `websearch` → `WebSearch` — the last two are resolved by Command Code's rule parser even though the docs only list the first six, and `Edit` covers Command Code's `write_file` tool as well as `edit_file`, so an `edit` allow also lets new files be created unless a `write` rule says otherwise, just as `Read` covers `grep` and `glob` (and `read_directory` / `read_multiple_files`) besides `read_file`, so a `read` rule governs them too — in both cases Command Code's `deny` > `ask` > `allow` precedence decides when the rules disagree; `notebookedit` and `agent` are skipped even though the parser resolves `NotebookEdit` and `Agent` too, because Command Code has no notebook tool — `NotebookEdit` is the same `edit_file` / `write_file` set as `Edit`, so a `notebookedit` rule would govern every file edit — and its permission check answers `allow` for the agent tool before it consults any rule, so an `Agent` deny or ask is never enforced), the `*` pattern of a category becomes the bare tool name, `mcp__<server>__<tool>` categories pass through verbatim (Command Code matches MCP server and tool names case-sensitively, so the category keeps its spelling) and the bare `mcp` category becomes `mcp__*` (Command Code ignores the `(specifier)` of an `mcp__` rule in every list and applies the rule to the whole tool, so a pattern-specific MCP `deny`/`ask` is written as the bare tool name with a warning, and a pattern-specific MCP `allow` — which would grant the whole tool — is not written, with a warning), and the all-tools `*` category becomes a bare `*` in `deny`/`ask` (Command Code ignores a tool-name wildcard such as `*` or `mcp__*` in `allow`, so it is not written there and a warning is logged; a pattern-specific rule under `*` is mirrored onto `Shell` when a `bash` category exists). A category with no rule name upstream is skipped, with a warning when it carries a `deny`. Note that a path-scoped `Read(pattern)`, `Grep(pattern)` or `Glob(pattern)` rule only matches a call that carries a `file_path` / `path` / `target_directory` input (Command Code's own semantics, as with Claude Code), so a `grep: { \"**/.env\": \"deny\" }` does not stop a `grep` over the working directory that names no path — use the bare `grep` / `glob` deny (the `*` pattern) or a `bash` deny when you need a guard. When two canonical rules collapse onto one entry the strictest action wins, matching Command Code's own `deny` > `ask` > `allow` precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: `defaultMode`, `additionalDirectories`, `disableBypass` and the other keys of the file are kept, and every existing entry for a tool the canonical config does not mention — an internal name such as `edit_file`, a name wildcard such as `edit_*`, or a modeled tool without a canonical category — is preserved verbatim, so the approvals Command Code writes into the same lists survive a regenerate. An entry for a rule name this run emits (case-insensitively for the friendly tool names, exactly for MCP names — `mcp__<server>` from the bare `mcp` category included, and `mcp__<server>__*` / `mcp__<server>__` are that same whole server) is replaced in whichever list it sits, so flipping a rule from `deny` to `allow` does not leave the old `deny` behind; a `mcp.<server>` rule you remove from the canonical config, however, leaves its `mcp__<server>` entry in place like any other unmentioned tool — delete it by hand. A `deny` or `ask` entry that is reclaimed this way without an equally strict rule — the same one, or the category-wide `*` rule that covers it — taking its place (a hand-written `mcp__*` deny next to a canonical `mcp: { github: \"deny\" }`, say) is reported with a warning, so a regenerate never loosens the file silently. A canonical block that names no category leaves every existing entry in place; a list that ends up empty after the rewrite is removed rather than written as `[]`, and the file itself is never deleted. On import the lists map back to `allow` / `ask` / `deny` as Command Code enforces each entry (the friendly tool names fold case, and the internal aliases Command Code's rule parser accepts for the same tools — `Bash`, `PowerShell`, `shell_command`, `monitor_command` and `kill_shell` for `bash`, `NotebookEdit` for `edit`, `write_file` for `write`, `web_fetch` / `web_search` for `webfetch` / `websearch` — fold onto their category, while `Agent` / `Task` enforce nothing and are skipped; `Tool`, `Tool()` and `Tool(*)` all mean the whole tool; the rule is split at its first unescaped `(` with the tool half trimmed and `\\(` / `\\)` in the specifier unescaped, as Command Code's own parser does, and a rule that opens a parenthesis without closing one is no rule at all and is skipped; a `Shell` specifier has its whitespace trimmed and collapsed the way Command Code's shell matcher does (`Shell( )` matches nothing and is skipped; a padded `Shell( * )` is a pattern rule, not the bare `Shell` — only a specifier of exactly `*` means the whole tool — so it imports as the whole `bash` category from `deny`/`ask`, where it matches every command, and is skipped with a warning from `allow`, where it is narrower than the bare `Shell`), whereas every other specifier is taken as written, so a padded `Read( * )` matches nothing and is skipped; a bare `:*` shell specifier (`Shell(:*)`), whose prefix is empty, matches nothing in Command Code either and is skipped rather than imported as an empty-prefix grant; an `mcp__<server>__<tool>(specifier)` imports as the whole tool from every list — which is what Command Code enforces for it, the specifier being ignored — with a warning from `allow`, where that widens the grant as written; MCP names keep their case; a `mcp__<server>`, `mcp__<server>__*` or `mcp__<server>__` entry becomes its own `mcp__<server>` category rather than a pattern of the bare `mcp` one, and a `*` server (`mcp__*`, `mcp__*__<tool>`) is every MCP tool, the bare `mcp` category; and a differently-cased `MCP__...` spelling, which Command Code reads as a plain tool-name rule because it only recognizes the MCP shape from an exact `mcp__` prefix, imports as what that rule does there: `MCP__<server>__<tool>` folds to the `mcp__` category spelled as written, with a warning that the exact-prefix rule generated from it matches the names exactly where the name rule matched them case-insensitively, `MCP__<server>__<tool>(specifier)` — whose specifier Command Code globs against the call's `command` / `file_path` / `path` / `url` / `pattern` argument — keeps its specifier as the pattern in `deny`/`ask` but is skipped in `allow` with a warning, where such a grant cannot be imported without widening it, the name globs `MCP__*` (every MCP tool, the bare `mcp` category) and `MCP__<server>__*` (the whole server, `mcp__<server>`) import from `deny`/`ask` only, and `MCP__<server>` names no tool and is left alone), with the strictest action winning for a tool named more than once, unmodeled entries skipped (with a warning when they sit in `deny` or `ask`, since an internal-name rule such as `edit_file(**/.env)` is enforced by Command Code even though rulesync cannot carry it to other targets; it stays in the settings file, where a regenerate keeps it), a tool-half glob in the exact-prefix spelling (`mcp__<server>__get_*`), which Command Code matches against tool names in every list, imported as the category spelled that way — the one the generator writes back — which other targets read as a literal tool name, a list entry that is not a string skipped on its own with a warning, as Command Code skips it (on regenerate such an entry is dropped from the rewritten list, also with a warning), and a server-less wildcard (`*`, `*()`, `mcp__*`, `mcp__*()`) in `allow` skipped with a warning because Command Code ignores it there. See the [permissions docs](https://commandcode.ai/docs/permissions).\n\nFor Letta Code, this generates the `permissions.allow`, `permissions.alwaysAsk` and `permissions.deny` arrays of the shared `.letta/settings.json` (project mode) or `~/.letta/settings.json` (global mode). Letta Code checks `deny`, then `alwaysAsk`, then its permission mode, and only then `allow` and its plain `ask` list, so a canonical `ask` is written to `alwaysAsk`: a plain `ask` entry would lose to a broader `allow` and to an auto-approving mode. An existing plain `ask` entry for a managed tool is therefore imported as `ask` and rewritten into `alwaysAsk` on the next generate. The categories map to Letta Code's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `glob` → `Glob`, `grep` → `Grep`); the `*` pattern of a category becomes the bare tool name. Other categories (`webfetch`, `websearch`, `mcp`, ...) have no rule in Letta Code and are skipped with a warning; a rule under the all-tools `*` category is applied to `Bash`. File-tool patterns are passed through as Letta Code globs. Letta Code's `ApplyPatch` tool takes no path rules, so `edit` and `write` path rules do not restrict it. Letta Code matches a `Bash` rule exactly, or as a prefix when it ends in `:*`, so a canonical pattern whose only `*` is trailing becomes a prefix rule — `git *` becomes `Bash(git:*)`, which also matches `gitk` since Letta Code trims the space before `:*` — and a pattern without `*` stays an exact match. A pattern with a `*` in the middle cannot be expressed: an `allow` is dropped with a warning, and a `deny` or `ask` is widened to the prefix before the first `*` with a warning, so it never loosens. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: `mode`, `additionalDirectories` and the other keys of `permissions`, and entries for other tools (`Task`, `ListDir`, ...), are kept, and a list that ends up empty is removed. On import `allow`, `ask`/`alwaysAsk` and `deny` map back to `allow`, `ask` and `deny` (Letta Code's aliases such as `shell` and `read_file` fold onto their category, `X:*` becomes `X *`), with the strictest action winning for a rule listed more than once. See the [permissions docs](https://docs.letta.com/configuration/permissions/index.md).\n\nFor Qoder, this generates the `permissions.allow`, `permissions.ask` and `permissions.deny` arrays of the shared `.qoder/settings.json` (project mode) or `~/.qoder/settings.json` (global mode); the per-machine `.qoder/settings.local.json`, where Qoder's `/allow` and `/deny` commands persist, is left to you. Qoder checks `deny`, then `ask`, then `allow`. The categories map to Qoder's tool names (`bash` → `Bash`, `read` → `Read`, `edit` → `Edit`, `write` → `Write`, `grep` → `Grep`, `glob` → `Glob`, `webfetch` → `WebFetch`, `websearch` → `WebSearch`, `agent` → `Agent`); the `*` pattern of a category becomes the bare tool name and any other pattern is written verbatim as `Tool(pattern)` — Qoder matches `Bash` payloads exactly, as a `:*` prefix or as a glob, file payloads as gitignore-style paths (`/src/**` is rooted at the project, or at the home directory in the user file), and `Agent` payloads as subagent names. Qoder checks path-scoped writes against `Edit(...)` rules and path-scoped reads against `Read(...)` rules, so a `write` rule with a path is written as `Edit(path)` and, following Claude Code (whose permission model Qoder mirrors; Qoder does not document `Glob` payloads), a `glob` rule with a path as `Read(path)`; such an entry is managed as an `Edit` / `Read` entry, so it is only rewritten on a later generate when the canonical config also names `edit` / `read`, and it imports back under `edit` / `read`. An `mcp__...` category (`mcp__github__create_issue`, `mcp__github__*`, `mcp__*`) is written as the bare MCP tool name; a narrower pattern under it is skipped with a warning. The all-tools `*` category's `*` pattern becomes Qoder's bare `*` rule; its other patterns are applied to `Bash` and otherwise skipped with a warning. Other categories (`notebookedit`, ...) are skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: `additionalDirectories`, `trustDirectories` and the other keys of `permissions`, and entries for other tools, are kept, and a list that ends up empty is removed. On import each entry maps back to its category and pattern verbatim (`Bash(git log:*)` stays `git log:*`), with the strictest action winning for a rule listed more than once. See the [permissions docs](https://docs.qoder.com/en/cli/permissions).\n\n> **Crush-only override (`crush` key):** Crush has no per-path or per-command permission model — its config carries two tool-wide lists, `permissions.allowed_tools` (tools that skip the confirmation prompt, as `tool` or `tool:action` entries) and `options.disabled_tools` (built-in tools removed from the agent altogether), written into the shared `crush.json` (project; `.crush.json` when that file already exists) / `~/.config/crush/crush.json` (global, via `--global`) by deep merge, so the sibling keys (`providers`, `mcp`, `hooks`, ...) survive and the file is never deleted. At project scope Crush merges `.crush.json` and `crush.json` (lists concatenated, `.crush.json` on top), so `allowed_tools`/`disabled_tools` entries left in the `crush.json` next to a `.crush.json` stay in effect and are named in a warning rather than retracted; import reads the merged pair. Each canonical category maps to a whole built-in tool name (`bash` → `bash`, `read` → `view`, `edit` → `edit`, `write` → `write`, `grep` → `grep`, `glob` → `glob`, `webfetch` → `fetch`; a `mcp__<server>__<tool>` category becomes Crush's `mcp_<server>_<tool>` name, and any other category is passed through as a tool name), so only the catch-all `*` pattern is written and narrower patterns (`git *`, `src/**`) are skipped with a warning — Crush prompts per call and cannot be told which arguments to approve. A catch-all `allow` lands in `permissions.allowed_tools`; a catch-all `deny` lands in `options.disabled_tools`, which only covers Crush's built-in tools, so a `deny` on an MCP tool is skipped with a warning (disable it through the server's `disabled_tools` in `.rulesync/mcp.jsonc` instead); `ask` writes nothing — a tool in neither list keeps Crush's default prompt. Because an `allowed_tools` entry approves every invocation of that tool, a catch-all `allow` is withheld (and named in a warning) when the same category also carries a narrower `deny` or `ask` — with `bash: { \"*\": \"allow\", \"rm -rf *\": \"deny\" }` nothing is written for `bash`, so Crush keeps prompting rather than auto-approving the denied commands. The all-tools `*` category has no Crush counterpart (there is no allow-all list, and emptying `disabled_tools` is not a disable-all) and is skipped with a warning. Entries in either list that rulesync did not derive — `tool:action` forms, MCP names, tools with no canonical category — are preserved on regenerate, and a rule that flips to `ask` (or a category whose catch-all is dropped while the category itself stays declared) retracts the entry it had written; removing a category wholesale leaves the entry rulesync can no longer tell apart from a hand-written one, so set it to `ask` first to retract it. Author the rest of the `permissions` block (`skip_requests`) and the other `options` keys directly in `crush.json`; they are untouched. On import, every plain tool entry in `permissions.allowed_tools` becomes `{ \"*\": \"allow\" }` for its category and every `options.disabled_tools` entry `{ \"*\": \"deny\" }` (deny wins when a tool is in both); `tool:action` entries are narrower than a tool-wide rule and are not imported. See the [permissions docs](https://github.com/charmbracelet/crush/blob/main/docs/config/README.md#permissions).\n\n> **Pool note:** Pool (Poolside's coding agent CLI) keeps its permission rules in the same YAML settings file as its MCP servers — `.poolside/settings.yaml` (project; the committed, team-shared file — the untracked `.poolside/settings.local.yaml` overlay is left to you) and `~/.config/poolside/settings.yaml` (global, via `--global`) — under two top-level keys, written by deep merge so the sibling keys (`pool`, `mcp_servers`, `sandbox`, `hooks`, ...) survive and the file is never deleted. Tool categories become `tools.<name>` blocks with `allow` and `deny` pattern lists matched against the tool's argument: `bash` → `tools.shell`, `webfetch` → `tools.web_fetch`, `websearch` → `tools.web_search`, and any other non-file category is passed through as `tools.<category>`. An `allow` rule lands in `allow`, a `deny` rule in `deny` (Pool lets a denied pattern win over an allowed one, so nothing is withheld for it), and `ask` writes nothing — a call that matches neither list prompts, which is Pool's default — except that an `allow` overlapping an `ask` of any category that lands on the same Pool tool (`bash` and a pass-through `shell` both write `tools.shell`, which Pool matches as one list) is withheld and named in a warning, since an allowed pattern would silence the prompt the config asks for. Pool's tool patterns know one wildcard, `*`, which matches anything including `/` (so a `**` run is written as `*`); a pattern that relies on `?`, `[...]` or `{a,b}` cannot be expressed and is skipped with a warning — and when it is a `deny`, it withholds the allows it overlaps on the same Pool tool just as an `ask` does, so the skipped deny is honoured by prompting rather than silently auto-approved. The file categories `read`, `edit` and `write` become `paths` entries instead, Pool's file-access model: a `read` allow is a read-only entry (`{ path }`), an `edit` or `write` allow adds `write: true` (Pool has a single write permission covering edits, creations, deletions and renames), and a `read` deny is a `paths.deny` entry, which wins over any allow; a bare `*` is written as `**` because a `*` in Pool's path globs is one segment, and since those globs know only `*` and `**`, a path pattern that relies on `?`, `[...]` or `{a,b}` is skipped with a warning — a skipped deny withholding the allows it overlaps, as on the tool side, rather than being written as a literal Pool would never match. Pool cannot deny writes without denying reads, so an `edit`/`write` deny that is not also a `read` deny is not written (a warning says so) and only withholds the write flag of the allows it overlaps — with `read: { \"src/**\": \"allow\" }, edit: { \"src/**\": \"allow\", \"src/generated/**\": \"deny\" }` the entry is written read-only and Pool prompts for the writes; an `ask` in a file category likewise withholds the overlapping allows (a write entry also grants reading, so a `read` ask withholds it too). Project settings take project-relative paths and the user settings absolute or `~` paths, so a path of the other kind is skipped with a warning at each scope (a `read` deny skipped that way exempts no write deny, since nothing lands in `paths.deny` for it). The all-tools `*` category has no Pool counterpart: its restrictions are mirrored onto `bash` and the category itself is skipped with a warning. A `mcp__<server>__<tool>` category is skipped too — Pool approves MCP tools per server under `mcp_servers.<server>.allow`, which the MCP feature writes from the server's `poolAllow` in `.rulesync/mcp.jsonc`. On regenerate the `allow`/`deny` lists of the tools the canonical config names are rebuilt (an empty list retracts its key) and each tool's `disabled` flag and every unnamed tool are left alone; removing a category wholesale leaves the lists rulesync can no longer tell apart from hand-written ones, so set its rules to `ask` first to retract them. The `paths` lists are rebuilt whenever the canonical config carries a file category and left untouched otherwise. On import, each `tools.<name>` block maps back to its category (`shell` → `bash`, `web_fetch` → `webfetch`, `web_search` → `websearch`, anything else verbatim) with its `allow`/`deny` patterns (deny wins when a pattern is in both) and `disabled: true` as `{ \"*\": \"deny\" }`; a `paths.allow` entry becomes a `read` allow (plus `edit` and `write` allows when it carries `write: true`) and a `paths.deny` entry a deny in all three; `mcp_servers` is not read. See the [settings file reference](https://docs.poolside.ai/settings-file-reference).\n\n> **Tabnine CLI-only override (`tabnine` key):** Tabnine CLI has no per-path or per-URL permission model — its `settings.json` carries two tool lists, `tools.allowed` (tools that skip the confirmation prompt) and `tools.exclude` (tools removed from discovery), written into the shared `.tabnine/agent/settings.json` (project) / `~/.tabnine/agent/settings.json` (global, via `--global`) by deep merge, so the sibling keys (`mcpServers`, `hooks`, `general`, `context`, ...) survive and the file is never deleted. From the canonical block, a `bash` rule whose pattern is a command prefix (`git *`, `pnpm`, or the bare `*` for the whole tool) becomes `run_shell_command(<prefix>)` — Tabnine matches the prefix against the start of the command line (so a bare `pnpm` also covers `pnpm install`), and any other shell pattern (`git * push`, `*.sh`) is skipped with a warning. A `bash` `deny` is written as `run_shell_command(<prefix>)` in `tools.exclude` — the form Gemini CLI, which Tabnine CLI derives from, honors in `excludeTools` — but Tabnine documents the prefix syntax for `tools.allowed` only, so that entry is not relied on to enforce the rule. Because a written prefix approves every longer command line too, each `bash` `allow` is compared at that width against every rule that restricts shell commands — a `bash` `deny` (written as a prefix or not), a `bash` `ask`, and the `deny` and `ask` rules of the all-tools `*` category — and is withheld when it overlaps one, rather than auto-approving the restricted commands: with `git *` allowed and `git push *` denied, a bare `pnpm` allowed and `pnpm publish *` asked about, or `git *` allowed and `git * --force` denied, the allow is not written, and each withheld rule is named in a warning. Every other category maps to a whole built-in tool name (`read` → `read_file`, `edit` → `replace`, `write` → `write_file`, `webfetch` → `web_fetch`, `websearch` → `google_web_search`, `grep` → `grep_search`, `glob` → `glob`; an unknown category is passed through as a tool name, which is how an MCP tool is named), so only the `*` pattern is written and narrower patterns are skipped with a warning. `allow` lands in `tools.allowed`, `deny` in `tools.exclude`, and `ask` writes nothing — a tool in neither list keeps Tabnine's default prompt. The rest of the `tools` group (`core`, `shell.*`, `enableWebTools`, ...) and the `general` group are authored through the `tabnine` override, which is deep-merged beneath the two canonical lists — only those two groups are written through the permissions feature; any other key of the override (`mcpServers`, `hooks`, ...) is ignored with a warning, since the adapter that owns it writes it. Six keys of those groups are refused outright with a warning rather than written — `tools.discoveryCommand`, `tools.callCommand`, `tools.shell.pager` and `tools.sandbox.command`, whose values Tabnine CLI spawns as commands, `general.tabnineHost`, the host every login and API call (and the credentials with them) goes to, and `general.preferredEditor`, an editor executable path — so a permissions file (one fetched with `rulesync fetch` included) cannot point it at an executable or a server of its choosing; set them by hand in `settings.json` if you need them. A `run_shell_command(<prefix>)` entry in the override's own `tools.allowed` is a `bash` allow spelled the Tabnine way, so it joins the canonical allows and is withheld by the same deny/ask comparison rather than written past it; a prefix spelled as a glob (`run_shell_command(git *)`, `run_shell_command(*)`) is read as that glob — Tabnine would match it literally — and written back as the prefix it denotes (`run_shell_command(git)`, `run_shell_command`) with a warning; import reads such an entry in `settings.json` back as the same glob. The override settings that widen what Tabnine does unattended are written as authored but named in a single trust-affecting warning: the override's other `tools.allowed` entries (a non-shell entry is only checked against a whole-tool deny of its tool: one naming a tool the canonical block excludes is withheld with a warning, since Tabnine never loads an excluded tool), a `tools.sandbox` that is not the plain `true`, and a `general.defaultApprovalMode` other than `default` or `plan`. Entries already in `tools.allowed`/`tools.exclude` that name a tool the canonical block does not mention (an MCP tool, a hand-written `run_shell_command(...)` prefix while the block has neither a `bash` nor a `*` rule) are kept; the entries of a tool the block does name are rewritten from it (an existing `tools.exclude` entry that goes this way is named in a warning, since removing it loosens the policy; a dropped `tools.allowed` entry only brings back the confirmation prompt and is named at info level), and the `*` category counts as naming the shell tool since its rules restrict shell commands. On import, list entries that cannot be mapped back (a prefix on a non-shell tool, a malformed entry, a tool whose name cannot be a permission category such as `__proto__`) round-trip through that override too; the import leaves those six refused keys in `settings.json` rather than lifting them (a generate would only refuse them) and announces the trust-affecting keys it does lift, so the resulting `.rulesync/permissions.jsonc` is read with the same eyes as one that arrived with a clone. See the [built-in tools docs](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/built-in-tools) and the [settings reference](https://docs.tabnine.com/main/getting-started/tabnine-cli/features/settings/settings-reference).\n\n> **Devin-only override (`devin` key):** the sibling top-level `sandbox` block decides what a permitted command may _reach_ rather than which commands are permitted, so it has no canonical category: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating allow), `network_mode` (`full`, the upstream default, allows every HTTP method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` / `deny` lists of `Exec(...)` matchers deciding which commands run _outside_ the sandbox: `allow` runs them outside it, `ask` asks first, and `deny` pins them inside it). Author it through a tool-scoped `devin` override; it is shallow-merged into the `sandbox` object at its top level, so the override's keys win while sibling keys you set directly are preserved, and the whole block round-trips back into the override on import. Devin documents `sandbox` as a **User Config Only** key, so it is written in global mode only — at project scope it is dropped with a warning rather than written into a file Devin would ignore. The values that loosen the sandbox — a `network_mode` set to anything but `limited`, and the `excluded.allow` / `excluded.ask` lists that take a command out of the sandbox entirely — are still written, but never silently: rulesync names them in one warning per file, as it does for Claude Code's trust-affecting `sandbox` paths, because a permissions file is shareable and `rulesync fetch` can copy one into a project. `allowed_domains` runs the other way — while it is non-empty it is an allowlist, so it is _adding_ to it, or emptying it back to \"every domain\", that widens — and because Devin replaces these lists rather than accumulating them, the same warning also names the restrictions a generate would drop: a `denied_domains` or `excluded.deny` that loses an entry the file had (a swap counts, not just a shorter list), an `excluded.deny` that disappears because the override states some other `excluded` key, or an `allowed_domains` that gains a domain or is emptied. Paths the override leaves alone are not reported, since the merge keeps them exactly as the file had them. The shallow merge only ever writes keys, so removing one from the override does not remove it from `config.json` — the value an earlier generate wrote stays until you delete it there by hand. The block only takes effect when Devin runs with `--sandbox` — either passed on the command line or forced on by an organization that sets sandbox enforcement to Required.\n>\n> ```json\n> {\n> \"permission\": { \"bash\": { \"git status *\": \"allow\" } },\n> \"devin\": {\n> \"sandbox\": {\n> \"allowed_domains\": [\"github.com\", \"**.npmjs.org\"],\n> \"network_mode\": \"limited\",\n> \"excluded\": { \"allow\": [\"Exec(git status *)\"], \"deny\": [\"Exec(git tag *)\"] }\n> }\n> }\n> }\n> ```\n>\n> See the [Devin sandbox docs](https://docs.devin.ai/cli/sandbox) and the [config-file reference](https://docs.devin.ai/cli/reference/configuration/config-file).\n\n> **Note: Interaction with deprecated ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. Migrate the ignore patterns to `read` deny rules in `.rulesync/permissions.jsonc`, then remove `ignore` from the project features and delete the obsolete ignore source.\n",
|
|
4326
4344
|
"reference/mcp-server": "# Rulesync MCP Server\n\nRulesync provides an MCP (Model Context Protocol) server that enables AI agents to manage your Rulesync files. This allows AI agents to discover, read, create, update, and delete files dynamically.\n\n> [!NOTE]\n> The MCP server exposes the only one tool to minimize your agent's token usage. Approximately less than 1k tokens for the tool definition.\n\n## Supported Features and Operations\n\nThe single `rulesyncTool` multiplexes by `feature` and `operation`:\n\n- `rule`, `command`, `subagent`, `skill`: `list`, `get`, `put`, `delete`\n- `ignore`, `mcp`, `permissions`, `hooks`: `get`, `put`, `delete`\n- `generate`: `run`\n- `import`: `run`\n- `convert`: `run`\n\nThe `permissions` feature operates on `.rulesync/permissions.jsonc` and the `hooks` feature operates on `.rulesync/hooks.jsonc`. Both accept a `content` string (valid JSONC) on `put`.\n\n### Warnings from `generate` / `run`, `import` / `run`, and `convert` / `run`\n\nThe server writes nothing to a console the calling agent can read, so a diagnostic raised while reading the source tool's files, or while generating — for example, that a machine-local overrides file such as `.factory/settings.local.json` was read into files rulesync commits — travels back in the result instead, as a `warnings` array of strings. The field is omitted when the operation had nothing to report, and is present on failures too, since a run that warned and then failed is exactly when the warnings matter. At most 100 warnings are returned, each truncated to 1,000 characters and 8,000 characters in total; a run that exceeds any of those limits says so in a final entry rather than growing the result without bound. These three operations are the only ones that report warnings — the `list` / `get` / `put` / `delete` operations read and write `.rulesync/` files that the caller can inspect for itself, and say nothing.\n\n### `rule` frontmatter\n\nThe `rule` operations expose the authored frontmatter, the value written in the file, not the resolved placement: `agentsmd.subprojectPath: \"auto\"` is returned as `\"auto\"`, whether or not a directory could be derived from `globs`, so a `get` → edit → `put` round trip leaves the request in place, and `put` answers with the frontmatter it wrote.\n\n### `subagent` paths\n\nUnlike `rule` and `command`, which take a file name and place it in their `.rulesync/` directory, the `subagent` operations take a `relativePathFromCwd` that must point inside `.rulesync/subagents/` and end in `.md`, for example `.rulesync/subagents/planner.md` or, for a nested agent, `.rulesync/subagents/review/security.md`. A bare file name, a path outside that directory, a non-Markdown file, a path with a dot-prefixed segment (which `list` and `generate` skip), or the directory itself is rejected. `list` returns the same paths, so they can be passed back to `get`, `put`, and `delete` unchanged. `.rulesync` and `.rulesync/subagents` may be symbolic links (for example into a dotfiles repository), and the server then reads and writes wherever the link points, as the CLI does; a symbolic link below `.rulesync/subagents/` is neither listed nor read, written, or deleted through.\n\n> [!WARNING]\n> Because a symlinked `.rulesync` or `.rulesync/subagents` is followed even when it points outside the project, running the MCP server in an untrusted checkout lets `subagent` `put` and `delete` create directories and create, overwrite, or remove `.md` files wherever a committed symbolic link points, and lets `list` and `get` read `.md` files from there. Inspect `.rulesync` before connecting an AI agent to the server in a repository you do not trust.\n\n### `skill` other files\n\nA skill directory may contain files other than `SKILL.md`. They are passed as `otherFiles`, where each entry has:\n\n| Field | Type | Required | Description |\n| ---------- | --------------------- | -------- | ------------------------------------------------------------------------------ |\n| `name` | `string` | Yes | Path of the file relative to the skill directory (e.g. `references/logo.png`). |\n| `body` | `string` | Yes | File content, encoded according to `encoding`. |\n| `encoding` | `\"utf-8\" \\| \"base64\"` | No | Defaults to `\"utf-8\"`. Use `\"base64\"` for binary files such as images. |\n\nOn `get`, every returned entry carries an explicit `encoding`: `\"utf-8\"` when the file content survives a UTF-8 round trip unchanged, and `\"base64\"` otherwise. On `put`, the declared `encoding` is trusted and the decoded bytes are written verbatim, so binary files round-trip byte for byte.\n\nWhen feeding entries returned by `get` back into `put`, keep their `encoding` field. Dropping it makes a `\"base64\"` body be stored as literal text and corrupts the file.\n\nA `\"base64\"` body must be canonical base64 (the standard or the URL-safe alphabet, padding optional); otherwise `put` fails with `Invalid base64 body for other file <name>`. The 1MB skill size limit is evaluated against the decoded byte length of each other file.\n\n### `convert` / `run` options\n\nWhen invoking `feature: \"convert\"` with `operation: \"run\"`, pass `convertOptions` with the following shape:\n\n| Option | Type | Required | Description |\n| ---------- | ---------- | -------- | ---------------------------------------------------------------------------------- |\n| `from` | `string` | Yes | Source tool name (e.g. `\"claudecode\"`). Must be a valid `ToolTarget`. |\n| `to` | `string[]` | Yes | One or more destination tool names. Must not be empty and must not include `from`. |\n| `features` | `string[]` | No | Features to convert (e.g. `[\"rules\", \"commands\"]`). Defaults to `[\"*\"]`. |\n| `global` | `boolean` | No | Convert global (user-scope) configurations. Defaults to `false`. |\n| `dryRun` | `boolean` | No | Preview changes without writing files. Defaults to `false`. |\n\n## Usage\n\n### Starting the MCP Server\n\n```bash\nrulesync mcp\n```\n\nThis starts an MCP server using stdio transport that AI agents can communicate with.\n\n### Configuration\n\nAdd the Rulesync MCP server to your `.rulesync/mcp.jsonc`:\n\n```json\n{\n \"$schema\": \"https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json\",\n \"mcpServers\": {\n \"rulesync-mcp\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"rulesync\", \"mcp\"],\n \"env\": {}\n }\n }\n}\n```\n",
|
|
4327
4345
|
"reference/supported-tools": "# Supported Tools and Features\n\nRulesync supports both **generation** and **import** for All of the major AI coding tools:\n\n<!-- SUPPORTED_TOOLS_DOCS:BEGIN -->\n\n| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | permissions | checks |\n| ------------------------- | ------------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | :---------: | :----: |\n| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | |\n| AgentsSkills | agentsskills | | | | | | ✅ 🌏 | | | |\n| Amp | amp | ✅ 🌏 | | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |\n| IBM Bob | bob | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🌏 | |\n| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Claude Code plugin | claudecode-plugin | | | ✅ | ✅ | ✅ | ✅ | ✅ | | |\n| CodeBuddy Code | codebuddy | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Codewhale | codewhale | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Command Code | commandcode | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |\n| GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Crush | crush | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Goose | goose | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 |\n| Hermes Agent | hermesagent | ✅ | ✅ | 🌏 🔧 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | ✅ |\n| Grok CLI | grokcli | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ |\n| deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | |\n| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ |\n| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| OpenClaw | openclaw | ✅ 🌏 | | | | | | | | |\n| MiMo Code | mimocode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| GitLab Duo CLI | gitlabduo | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ |\n| Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |\n| Kilo Code | kilo | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ |\n| Kimi Code | kimi-code | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 | |\n| Kimi Code plugin | kimi-code-plugin | ✅ | | | ✅ | ✅ | ✅ | | | |\n| Roo Code ⚠️ | roo | ✅ 🌏 | ✅ | ✅ 🔧 | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ | |\n| Zoo Code | zoocode | ✅ 🌏 | ✅ | ✅ 🔧 | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ | |\n| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ |\n| Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 |\n| Trae | trae | ✅ | | ✅ | | | ✅ 🌏 | | | |\n| Tabnine CLI ⚠️ | tabnine | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Continue ⚠️ | continue | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | |\n| Snowflake Cortex Code | cortexcode | ✅ | | 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | |\n| Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Vibe Code plugin | vibe-plugin | | | ✅ | | ✅ | ✅ | ✅ | | |\n| Qoder | qoder | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ |\n| Meta Muse Code | musecode | ✅ | | 🌏 | | | ✅ 🌏 | | | |\n| Reasonix | reasonix | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Codebuff (Freebuff) | codebuff | | ✅ | ✅ 🌏 | | | | | | |\n| Kiro ⚠️ | kiro | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ | ✅ | ✅ | ✅ | ✅ | |\n| Kiro CLI | kiro-cli | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |\n| Kiro IDE | kiro-ide | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |\n| Letta Code | lettacode | | ✅ | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Google Antigravity IDE | antigravity-ide | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |\n| Google Antigravity CLI | antigravity-cli | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | |\n| Google Antigravity plugin | antigravity-plugin | ✅ | | ✅ 🔧 | | ✅ | ✅ | ✅ | | |\n| JetBrains AI Assistant | aiassistant | ✅ | ✅ | ✅ 🌏 | | | ✅ | | | |\n| JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 | |\n| AugmentCode | augmentcode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ |\n| AugmentCode plugin | augmentcode-plugin | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | |\n| Devin Desktop | devin | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Devin plugin | devin-plugin | ✅ | | ✅ 🔧 | | ✅ | ✅ | ✅ | | |\n| Warp | warp | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | 🌏 | |\n| Warp Agent CLI | warpcli | | | 🌏 | | | | | 🌏 | |\n| Replit | replit | ✅ | | | | | ✅ 🌏 | | | |\n| Pi Coding Agent | pi | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| oh-my-pi | omp | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| Zed | zed | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | |\n| ZCode (Z.ai) | zcode | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | |\n| ZCode plugin | zcode-plugin | | | ✅ | ✅ | ✅ | ✅ | ✅ | | |\n| Pool (Poolside) | pool | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |\n| DeepSeek Harness | dsh | ✅ 🌏 | | 🌏 | | | ✅ 🌏 | | | |\n\n<!-- SUPPORTED_TOOLS_DOCS:END -->\n\n- ✅: Supports project mode\n- 🌏: Supports global mode\n- 🎮: Supports simulated commands/subagents/skills (Project mode only)\n- 🔧: Supports MCP tool config (`enabledTools`/`disabledTools`)\n- ⚠️: Deprecated — still supported, but see the note below\n\n## Hermes Agent compatibility\n\nThe `hermesagent` target is validated against Hermes Agent v0.21.6 (release\n`v0.21.6`). The supported contract covers project rules, ignore patterns,\nsubagents, and checks, plus global MCP servers, commands, subagents, skills,\nhooks, and permissions. Generation, `--check`, and import round-trips are\ncovered for both advertised scopes.\n\nRulesync honors Hermes profiles through `HERMES_HOME`. When it is set, its value\nis the profile root itself: global configuration is read and written directly\nunder `$HERMES_HOME` (`config.yaml`, `skills/`, `plugins/`, and `rulesync/`),\nwithout appending `.hermes`. When it is unset, Rulesync follows Hermes's own\nplatform default: `~/.hermes` everywhere except Windows, where it is\n`%LOCALAPPDATA%\\hermes`. Because `HERMES_HOME` names where Hermes itself reads\nthe profile, it also takes precedence over `--output-roots` in global scope.\nProject-scoped paths remain rooted in the project.\n\nChanging which profile root Rulesync resolves strands whatever it generated\nunder the previous one. `--delete` reconciles only the root resolved for the\ncurrent run, so files under a root it no longer resolves are invisible to it and\nmust be removed by hand once you are sure Hermes no longer reads them. This\napplies whenever you set or change `HERMES_HOME`, and to two upgrades that moved\nthe resolved root on their own: before v16.0.0 global files went to `~/.hermes`\neven when `HERMES_HOME` was set, and before v16.2.0 they went there on Windows\ntoo, rather than to `%LOCALAPPDATA%\\hermes`.\n\nProject plugins are registered by adding their names to\n`$HERMES_HOME/config.yaml`, but Rulesync does not persist Hermes's global\nproject-plugin trust gate. Run Hermes from a trusted project root with\n`HERMES_ENABLE_PROJECT_PLUGINS=true` for an explicit, session-scoped opt-in. A\nfuture Hermes release that changes its loaders, schemas, or plugin API requires\na new compatibility validation.\n\n## Deprecation notes\n\n- **Google Antigravity (`antigravity-ide` / `antigravity-cli`)** — Antigravity 2.0 splits into two products: the desktop **`antigravity-ide`** and the **`antigravity-cli`** (`agy`). As of Antigravity 2.0 the IDE reads its global MCP config and skills from the shared `~/.gemini/config/` tree — `~/.gemini/config/mcp_config.json` and `~/.gemini/config/skills/`, matching the current [MCP](https://antigravity.google/docs/mcp) and [Skills](https://antigravity.google/docs/skills) docs. The `antigravity-cli` global MCP config also lives in the shared `~/.gemini/config/mcp_config.json`, while the CLI keeps its own global skills tree at `~/.gemini/antigravity-cli/skills/`. Both targets also intentionally **share** the global rule file `~/.gemini/GEMINI.md` and the global hooks file `~/.gemini/config/hooks.json` — enabling both targets in `--global` mode writes those shared files once. For project-scope rules, **both `antigravity-ide` and `antigravity-cli`** emit the root rule as a plain cross-tool **`AGENTS.md`** at the project root (the Gemini-lineage discovery order is `AGENTS.md`, `CONTEXT.md`, `GEMINI.md`; the IDE has read `AGENTS.md` since v1.20.3) and non-root rules under `.agents/rules/` (both add trigger frontmatter to non-root rules, because Antigravity silently discards any `.md` file under a `rules/` directory that lacks a valid `trigger`, so the two targets write identical files there). In `--global` mode, both targets keep the root rule in `~/.gemini/GEMINI.md` and write each non-root rule to its own file in `~/.gemini/config/rules/` with trigger frontmatter (`always_on` unless the rule's globs or its `antigravity.trigger` say otherwise), because Antigravity truncates every rule file at 24,000 bytes and loads that directory as separate global rules (see the [Antigravity rules docs](https://antigravity.google/docs/rules)). A nested rule is flattened to a top-level name there (`frontend/style.md` → `frontend-style.md`), since Antigravity reads only that directory's top level. The two targets write identical files there. The directory holds global rules created outside rulesync too, so `generate --delete` never removes files there: after deleting or renaming a rulesync rule, remove its old file by hand. The per-file limit is not the only cap: always-on and global rules also share a 20,000-token budget, over which rules are demoted to path pointers. For **commands**, Antigravity retires workflows on October 19, 2026 and documents [skills as their replacement](https://antigravity.google/docs/migration/workflows-to-skills) (a skill is invoked as `/<skill-name>` exactly like a workflow, and wins over a workflow of the same name), so both targets emit each command as a skill: `.agents/skills/<name>/SKILL.md` in the project, and `~/.gemini/config/skills/<name>/SKILL.md` (IDE) or `~/.gemini/antigravity-cli/skills/<name>/SKILL.md` (CLI) in `--global` mode — the same directories the skills feature writes. `<name>` is the command's trigger (`antigravity.trigger`, then a root `trigger`, then a `trigger: /name` line in the body, defaulting to the filename), and the skill gets `name`/`description` frontmatter. A rulesync skill with the same name that is written to the same skills directory (it targets `*` or a tool sharing that directory) takes precedence: the command is skipped with a warning, whichever targets and features a given run includes, so a partial run never overwrites the skill. The commands feature does not sweep the shared skills tree, so the skill of a deleted command is removed by `generate --delete` with the skills feature enabled. `rulesync import` still reads legacy workflows from `.agents/workflows/` (project) and, in `--global` mode, from `~/.gemini/config/workflows/` (IDE; the Antigravity 2.0 global workflows directory named by the migration guide) or `~/.gemini/antigravity-cli/global_workflows/` (CLI), and `generate --delete` removes every `*.md` file left in those workflow directories, including hand-written ones (import them first to keep them). In `--global` mode, `antigravity-ide` import also reads the pre-2.0 `~/.gemini/antigravity/global_workflows/` directory, where earlier rulesync versions wrote IDE global commands; a workflow with the same file name in `~/.gemini/config/workflows/` wins, and `generate --delete` never sweeps the pre-2.0 directory.\n- **Continue (`continue`)** — Continue is end of life: Continue [joined Cursor](https://continue.dev) on 2026-06-18, the [continuedev/continue repository](https://github.com/continuedev/continue) is read-only with a final **v2.0.0** VS Code release (2026-06-19) and its last commit on 2026-07-21, and the in-product deprecation banner expired on 2026-09-09. The `continue` target stays supported as a frozen-compatibility target: every adapter was verified against that final source tree (commit [`5522c6f4`](https://github.com/continuedev/continue/tree/5522c6f44ca0ac3528b37244818fbfa39b5af470), which the source links in the code and docs are pinned to), and nothing about the file formats will move again, so existing output keeps working for anyone still running the last release. Do not expect new Continue capabilities to be tracked; new projects should pick an actively maintained tool instead.\n- **Kiro (`kiro`)** — Kiro ships as two products with diverging config formats: the **Kiro IDE** reads Markdown subagents (`.kiro/agents/*.md`) and structured JSON hooks (`.kiro/hooks/*.json`, format `{ \"version\": \"v1\", \"hooks\": [ ... ] }`), while the **Kiro CLI** reads JSON agent-config subagents (`.kiro/agents/*.json`). A single target cannot emit both subagent shapes faithfully, so `kiro` is split into **`kiro-cli`** and **`kiro-ide`**. The legacy `kiro` target is kept as a **deprecated alias** (its current mixed output is unchanged for backward compatibility). Shared surfaces (steering rules with `inclusion`, `.kiro/settings/mcp.json`, `.kiro/prompts/` commands, `.kiro/skills/`, `.kiroignore`, permissions) are identical between the two; they differ only in **subagents** (`.md` vs `.json`). **Hooks** are the same for both: a single `.kiro/hooks/rulesync.json` (whose `hooks` array holds every generated hook) in both project (`.kiro/hooks/`) and global (`~/.kiro/hooks/`) scope, mapping canonical lifecycle events to Kiro's PascalCase triggers (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`) and supporting both `agent` (prompt) and `command` actions. Kiro CLI 3.0 [migrated to that format](https://kiro.dev/docs/cli/v3/hooks-migration/) and no longer reads the embedded agent hooks in `.kiro/agents/default.json`, which only the deprecated `kiro` alias still writes (including `cacheTtl` ⇄ `cache_ttl_seconds`). Global **skills** (`~/.kiro/skills/`), global **ignore** (`~/.kiro/settings/kiroignore`, which only the IDE reads, so `kiro-cli` skips it), and global Kiro IDE **subagents** (`~/.kiro/agents/`) are also supported, as are global Kiro CLI **commands** (`~/.kiro/prompts/`) and **subagents** (`~/.kiro/agents/`). Kiro's shared MCP file preserves per-server `disabledTools`.\n- **Roo Code (`roo`)** — Roo Code is end of life: its final release was **v3.54.0 (2026-05-15)** and the [Roo-Code repository](https://github.com/RooCodeInc/Roo-Code) is archived, so nothing about the target will move again. New projects should target **`zoocode`** instead — [Zoo Code](https://github.com/Zoo-Code-Org/Zoo-Code) is the community continuation named by the Roo shutdown notice, and it continues Roo's release numbering. The `roo` target stays supported because Zoo Code still reads the `.roo/` project tree and `~/.roo` global tree verbatim, so existing `roo` output keeps working; what it no longer tracks is anything Zoo Code added after the fork. The two targets write the same files, so enable one per project rather than both — see the Zoo Code note in [File formats](./file-formats.md) for the fail-open hazard a `--targets roo` generate creates in a shared `.roomodes`. **Permissions are the one exception**: the two lineages spell the command allow/deny lists differently (`roo-cline.*` vs `zoo-code.*`), so both pairs coexist in one `.vscode/settings.json` and neither target touches the other's — see the Roo Code paragraph in [File formats](./file-formats.md).\n- **Tabnine CLI (`tabnine`)** — The file-based Tabnine CLI this target models is in [maintenance mode](https://docs.tabnine.com/main/getting-started/tabnine-cli): as of Tabnine **6.6** (2026-09-15) it is [\"Tabnine CLI (Legacy)\"](https://docs.tabnine.com/main/administering-tabnine/release-notes), installed as `tabnine-legacy` once the new CLI is present, and it receives only critical updates through **2026-12-31**, after which Tabnine deprecates it fully. The current `tabnine` command is [Tabnine's distribution of OpenCode](https://docs.tabnine.com/main/getting-started/tabnine-plugin-for-opencode), configured with standard OpenCode files — so for the new CLI use **`--targets opencode`** (`AGENTS.md`, `opencode.json`, `.opencode/{commands,agents,skills}`, `~/.config/opencode/**`); its [migration helper](https://docs.tabnine.com/main/getting-started/tabnine-plugin-for-opencode/migrating-from-tabnine-cli) (`/migrate`, `/migrate-context`) carries MCP servers, skills, agents, commands and `TABNINE.md` over. The new CLI adds `~/.tabnine/opencode/config/` as an extra config root but still reads the standard OpenCode roots — project `opencode.json(c)` and `.opencode/`, and `~/.config/opencode/**` ([configuration docs](https://docs.tabnine.com/main/getting-started/tabnine-plugin-for-opencode/configuration)) — so `--targets opencode` output (including `--global`) is picked up; keys set in Tabnine's own `opencode.json` there are layered last and win over the same keys in the generated one. A global `AGENTS.md` is not read from `~/.tabnine/opencode/config/`; it comes from `~/.config/opencode/AGENTS.md`, where Rulesync writes it. The `tabnine` target stays supported for the legacy CLI (0.36.0 as of Tabnine 6.6.5) until then; whether it is removed in a later major is decided separately.\n",
|
|
4328
4346
|
"tools/takt": "# Takt\n\n[Takt](https://github.com/nrslib/takt) is a faceted-prompting AI coding workflow tool. Rulesync generates plain-Markdown facet files into Takt's `.takt/facets/` layout (or `~/.takt/facets/` in global mode).\n\n## Output mapping\n\nEach rulesync feature maps onto a dedicated Takt facet directory. The target directory is fixed per feature, except that **rules** may opt into Takt's fifth facet — `output-contracts` — via the `takt.facet` override (see below).\n\n| Rulesync feature | Takt facet directory |\n| ---------------- | --------------------------------------------------------------------------------------- |\n| `rules` | `.takt/facets/policies/` (default) or `.takt/facets/output-contracts/` via `takt.facet` |\n| `commands` | `.takt/facets/instructions/` |\n| `subagents` | `.takt/facets/personas/` |\n| `skills` | `.takt/facets/knowledge/` |\n\nTakt-specific frontmatter knobs:\n\n```yaml\n---\ntakt:\n name: my-renamed-stem # rename the emitted filename stem\n extends: base # emit a leading {extends:base} facet-inheritance directive\n facet: output-contracts # \"policies\" (default) or \"output-contracts\"\n---\n```\n\n- `takt.name` is **optional**; the source filename stem is used by default. Unsafe values (path separators, `..` segments, etc.) raise a hard validation error at `generate` time.\n- `takt.facet` is **optional** and defaults to `policies`. Setting it to `output-contracts` redirects the rule to Takt's output-structure / report-template facet, which has no dedicated rulesync feature. Both `policies` and `output-contracts` support `{extends:...}` inheritance. The other facets (`instructions`, `personas`, `knowledge`) are owned by the commands, subagents, and skills features and are not selectable via `takt.facet`.\n- Like `takt.name` and `takt.extends`, `takt.facet` is a generate-side authoring control. Because Takt facet files are plain Markdown with no frontmatter, the facet selection cannot be recovered on import (see [Importing](#importing-existing-takt-files-into-rulesync) below).\n\nOutput files are **plain Markdown** — the source frontmatter is dropped entirely and the body is written verbatim:\n\n```\n.rulesync/rules/style.md → .takt/facets/policies/style.md\n.rulesync/rules/review-format.md → .takt/facets/output-contracts/review-format.md (with takt.facet: output-contracts)\n.rulesync/commands/review.md → .takt/facets/instructions/review.md\n.rulesync/subagents/coder.md → .takt/facets/personas/coder.md\n.rulesync/skills/oncall/SKILL.md → .takt/facets/knowledge/oncall.md\n```\n\n## MCP (partial — transport allowlist only)\n\nTakt has no project- or global-level registry of MCP server _definitions_: the concrete `mcp_servers` map (`command`/`args`/`env` or `type`/`url`/`headers`) is declared **per workflow step** inside individual workflow YAML files, and Takt's `config.yaml` loader rejects unknown top-level keys. The one MCP knob `config.yaml` does expose is the **default-deny transport allowlist** `workflow_mcp_servers: { stdio, sse, http }`; until a transport is enabled there, every workflow-defined MCP server using it is refused.\n\nRulesync therefore emits **only** this allowlist into the shared `.takt/config.yaml` (project) / `~/.takt/config.yaml` (global), turning on exactly the transports the servers in `.rulesync/mcp.jsonc` use (`local`/`stdio` → `stdio`, `sse` → `sse`, `http`/`streamable-http`/`ws` → `http`). The merge is in place, so the active provider, provider profiles, and all other config keys are preserved; the file is never deleted.\n\n**Lossiness:** the per-server names, commands, env, URLs, and headers are not representable in `config.yaml` and are intentionally not written — you still declare the concrete servers in your workflow YAML steps; Rulesync only opens the transport gate that permits them. Because of this, reverse import cannot reconstruct server definitions and yields an empty `mcpServers` map.\n\n## Checks — quality gates\n\n`.rulesync/checks/*.md` become TAKT **quality gates** in the `workflow_overrides` block of the shared `config.yaml`. A check's body is a string gate — a completion directive TAKT injects into the agent step prompt — unless the check's `takt` frontmatter block names a `command`, which makes it a command gate TAKT runs after the step, failing the gate on a non-zero exit.\n\n**A command gate needs no policy switch to run.** TAKT's default-deny `workflow_command_gates.custom_scripts` policy applies to gates declared in workflow YAML, not to gates coming from `workflow_overrides`, so a `takt.command` in a check runs on every step it applies to without the user having enabled anything. Read the frontmatter of any check you obtain with `rulesync fetch` before generating. Since TAKT 0.62.0 the gate runs after rule resolution and before the transition is applied, and a workflow rule carrying `command_gates: skip` applies its transition without running it — so a gate is not a guaranteed post-step check either; a `needs_fix` or `ABORT` transition can leave a step even when its gate would fail.\n\n**Lossiness:** TAKT gates carry no severity or tool allowlist, so a check's `severity` and `tools` fields are not written and do not come back on import.\n\n`quality_gates_edit_only` in a check's `takt` block applies to the whole block, and reaches only the gates with no `steps` / `personas` scope — TAKT runs a scoped gate whether or not the step may edit files.\n\nThe block is owned by the checks feature: it is rewritten from `.rulesync/checks/` on every generate, and retracted when checks remain but none target TAKT. Emptying `.rulesync/checks/` altogether leaves the gates in place — the feature has no source to generate from — so delete them by hand in that case. See [file formats](../reference/file-formats.md) for the frontmatter reference.\n\n## Scope\n\nBoth project mode (`.takt/facets/...`, `.takt/config.yaml`) and global mode (`~/.takt/facets/...`, `~/.takt/config.yaml`) are supported.\n\n## `--delete` and `.takt/facets/knowledge/`\n\nSkills are written as flat files sharing one facet root instead of each getting a directory of its own, so `generate --delete` sweeps `.takt/facets/knowledge/` (or `~/.takt/facets/knowledge/` in global mode) by file name rather than by directory: a `.md` file directly under the root that no `.rulesync/skills/` source produces is deleted. That is what makes a renamed or deleted skill stop being served to TAKT. The other facets are unaffected — rules, commands, and subagents each get their own directory and are swept normally.\n\nThe root itself is never deleted, and neither is anything nested inside it: a subdirectory under `.takt/facets/knowledge/` is left alone, files and all. Symbolic links and hidden dotfiles in the root are skipped too, and so is any file whose name TAKT itself could never have written — a facet name is limited to ASCII letters, digits, `_`, `-`, and `.`, so `Design Doc.md` or `設計メモ.md` is never touched.\n\nA hand-authored `.md` placed **directly** in the facet root, under a name that _does_ look generated, is another matter. It is indistinguishable from a real skill file, so `--delete` sweeps it — the same as for `policies/`, `instructions/`, and `personas/`, which the rules, commands, and subagents features own outright. Keep hand-authored knowledge in a subdirectory (for example `.takt/facets/knowledge/my-notes/`) to put it out of the sweep's reach. The same applies in global mode: `~/.takt/facets/knowledge/` is swept exactly like the project root.\n\nThe sweep only runs for a root this generate wrote a file into. If no skill targets TAKT — or `.rulesync/skills/` is empty — the facet root has no rulesync source behind it, so nothing in it is touched. That also means emptying `.rulesync/skills/` altogether leaves the last generated files in place, the same as the checks block above: delete them by hand in that case.\n\nTwo gaps are worth knowing about. A skill's companion files are flattened into the same root, and only the `.md` files directly under it are swept: a companion that is not Markdown (`runbook.txt`) or that sits in a subdirectory of its own (`refs/notes.md`) survives its skill's removal and has to be deleted by hand. And a skill whose `takt.name` starts with a dot writes a hidden file, which the sweep does not enumerate, so that one is never swept either.\n\n## Importing existing TAKT files into rulesync\n\nImporting the **facet** features (rules, commands, subagents, skills) is **not supported**. TAKT facet files are plain Markdown with no frontmatter, so the original skill / command / subagent metadata cannot be recovered. Attempting to import a TAKT skill raises a clear error rather than silently producing a stub that round-trips badly.\n\nThe `config.yaml` features do import: `rulesync import --targets takt --features checks` reads the quality gates back into `.rulesync/checks/`, and `--features permissions` reads the permission mode and the Takt-specific override keys. MCP is the exception noted above — the allowlist carries no server definitions to reconstruct.\n"
|
|
@@ -13319,7 +13337,7 @@ function wrapCommand$1({ name, errorCode, handler, getVersion, loggerFactory = c
|
|
|
13319
13337
|
}
|
|
13320
13338
|
//#endregion
|
|
13321
13339
|
//#region src/cli/program.ts
|
|
13322
|
-
const getVersion = () => "
|
|
13340
|
+
const getVersion = () => "33.0.0";
|
|
13323
13341
|
const FEATURES_HELP = `${ALL_FEATURES.join(",")}; ignore is deprecated, use permissions`;
|
|
13324
13342
|
function wrapCommand(name, errorCode, handler) {
|
|
13325
13343
|
return wrapCommand$1({
|