Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rulesync/rules/overview.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
root: true # true that is less than or equal to one file for overview such as AGENTS.md, false for details such as .agents/memories/*.md
root: true # true for overview fragments such as AGENTS.md; multiple root files can be composed for the same target. false for details such as .agents/memories/*.md
targets: ["*"] # * = all, or specific tools
description: "rulesync project overview and development guidelines for unified AI rules management CLI tool"
globs: ["**/*"] # file patterns to match (e.g., ["*.md", "*.txt"])
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/global-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@ Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCo
> Currently, when in the directory enabled global mode:
>
> - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored.
> - Tools support only a single `root: true` file in global mode as a target, e.g. you can't have 2 root files targeting Claude.
> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or plain-Markdown single-file outputs are combined with a blank line between files. Local rules are composed first in lexicographic source file path order, followed by non-overridden `.curated/` rules in lexicographic order, so filename prefixes control composition order within each set. Distinct native paths remain separate. Explicitly supported plain-Markdown modular path collisions are combined (fragments whose generated output carries its own frontmatter block stay separate), unsafe collisions involving a source root rule fail, and other exact or case-insensitive modular collisions warn that the last write wins wherever their paths collide.
> - Only Claude Code is supported for global mode commands.
4 changes: 3 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Example:

```md
---
root: true # true that is less than or equal to one file for overview such as `AGENTS.md`, false for details such as `.agents/memories/*.md`
root: true # true for root-level rules, false for details such as `.agents/memories/*.md`
localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI) and Roo Code: AGENTS.local.md; Others: append to root file
targets: ["*"] # * = all, or specific tools
description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool"
Expand Down Expand Up @@ -61,6 +61,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration
...
```

Multiple 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.

> **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`. 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/>.

> **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. 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`.
Expand Down
4 changes: 3 additions & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Example:

```md
---
root: true # true that is less than or equal to one file for overview such as `AGENTS.md`, false for details such as `.agents/memories/*.md`
root: true # true for root-level rules, false for details such as `.agents/memories/*.md`
localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI) and Roo Code: AGENTS.local.md; Others: append to root file
targets: ["*"] # * = all, or specific tools
description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool"
Expand Down Expand Up @@ -61,6 +61,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration
...
```

Multiple 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.

> **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`. 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/>.

> **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. 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`.
Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/global-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@ Currently, supports rules generation for Claude Code, GitHub Copilot, and OpenCo
> Currently, when in the directory enabled global mode:
>
> - `rulesync.jsonc` only supports `global`, `features`, `delete` and `verbose`. `Features` can be set `"rules"` and `"commands"`. Other parameters are ignored.
> - Tools support only a single `root: true` file in global mode as a target, e.g. you can't have 2 root files targeting Claude.
> - Multiple `root: true` files can target the same tool in project and global modes. Compatible root or plain-Markdown single-file outputs are combined with a blank line between files. Local rules are composed first in lexicographic source file path order, followed by non-overridden `.curated/` rules in lexicographic order, so filename prefixes control composition order within each set. Distinct native paths remain separate. Explicitly supported plain-Markdown modular path collisions are combined (fragments whose generated output carries its own frontmatter block stay separate), unsafe collisions involving a source root rule fail, and other exact or case-insensitive modular collisions warn that the last write wins wherever their paths collide.
> - Only Claude Code is supported for global mode commands.
119 changes: 119 additions & 0 deletions src/e2e/e2e-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,59 @@ This is a test rule for E2E testing.
},
);

it.each([
{
target: "claudecode",
outputPaths: ["CLAUDE.md"],
},
{
target: "cursor",
outputPaths: [
join(".cursor", "rules", "overview.mdc"),
join(".cursor", "rules", "additional-project-rule.mdc"),
],
},
] as const)(
"should preserve multiple project root rules for $target",
async ({ target, outputPaths }) => {
const testDir = getTestDir();
const rootRuleContent = `---
root: true
targets: ["*"]
description: "Project root rule"
---

# Project Root Fragment
`;
const additionalRuleContent = `---
root: true
targets: ["*"]
description: "Additional project root rule"
---

# Additional Project Root Fragment
`;
await writeFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME),
rootRuleContent,
);
await writeFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-project-rule.md"),
additionalRuleContent,
);

await runGenerate({ target, features: "rules" });

const generatedContent = (
await Promise.all(
outputPaths.map((outputPath) => readFileContent(join(testDir, outputPath))),
)
).join("\n");
expect(generatedContent.split("# Project Root Fragment")).toHaveLength(2);
expect(generatedContent.split("# Additional Project Root Fragment")).toHaveLength(2);
},
);

it("should fold pi non-root rules into the root AGENTS.md", async () => {
const testDir = getTestDir();

Expand Down Expand Up @@ -892,6 +945,72 @@ This is a global test rule for E2E testing.
},
);

it.each([
{
target: "claudecode",
outputPaths: [join(".claude", "CLAUDE.md")],
},
{
target: "augmentcode",
outputPaths: [
join(".augment", "rules", "overview.md"),
join(".augment", "rules", "additional-global-rule.md"),
],
},
{
target: "takt",
outputPaths: [
join(".takt", "facets", "policies", "overview.md"),
join(".takt", "facets", "policies", "additional-global-rule.md"),
],
},
] as const)(
"should preserve multiple global root rules for $target",
async ({ target, outputPaths }) => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();
const rootRuleContent = `---
root: true
targets: ["*"]
description: "Global root rule"
---

# Global Root Fragment
`;
const additionalRuleContent = `---
root: true
targets: ["*"]
description: "Additional global root rule"
---

# Additional Global Root Fragment
`;
await writeFileContent(
join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME),
rootRuleContent,
);
await writeFileContent(
join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "additional-global-rule.md"),
additionalRuleContent,
);

await runGenerate({
target,
features: "rules",
global: true,
env: { HOME_DIR: homeDir },
});

const generatedContent = (
await Promise.all(
outputPaths.map((outputPath) => readFileContent(join(homeDir, outputPath))),
)
).join("\n");
expect(generatedContent.split("# Global Root Fragment")).toHaveLength(2);
expect(generatedContent.split("# Additional Global Root Fragment")).toHaveLength(2);
},
);

it("should ignore non-root rules in global mode", async () => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();
Expand Down
Loading
Loading