Skip to content
Merged
8 changes: 8 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Rulesync follows symbolic links when it discovers source files, whether you use

The 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: results are deduplicated by real path, so a cycle does not produce duplicated output. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that **skips** symlinks entirely, so untrusted remote content never has its symlinks followed.

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

## `rulesync/rules/*.md`

Example:
Expand Down Expand Up @@ -56,6 +58,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration
...
```

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

> **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, and the `instructions` list is deduped and sorted.
Expand Down Expand Up @@ -674,6 +678,10 @@ Skills are directory-based and can include additional files alongside SKILL.md.
When `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 `"*"`.
```

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

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

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

> **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, Rulesync emits `.agents/skills/<name>/agents/openai.yaml` and reads it back on import. 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).
Expand Down
8 changes: 8 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Rulesync follows symbolic links when it discovers source files, whether you use

The 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: results are deduplicated by real path, so a cycle does not produce duplicated output. Note that the remote-fetch path (`rulesync fetch` from a Git repository) is a separate, hardened code path that **skips** symlinks entirely, so untrusted remote content never has its symlinks followed.

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

## `rulesync/rules/*.md`

Example:
Expand Down Expand Up @@ -56,6 +58,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration
...
```

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

> **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, and the `instructions` list is deduped and sorted.
Expand Down Expand Up @@ -674,6 +678,10 @@ Skills are directory-based and can include additional files alongside SKILL.md.
When `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 `"*"`.
```

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

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

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

> **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, Rulesync emits `.agents/skills/<name>/agents/openai.yaml` and reads it back on import. 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).
Expand Down
30 changes: 30 additions & 0 deletions src/e2e/e2e-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,36 @@ This is a test project for E2E testing.
const importedContent = await readFileContent(importedRulePath);
expect(importedContent).toContain("Project Overview");
});

// Nested `AGENTS.md` files are the AGENTS.md standard's only scoping
// mechanism ("agents automatically read the nearest file in the directory
// tree"). https://agents.md/
it("should import nested agentsmd rules and round-trip their subproject scope", async () => {
const testDir = getTestDir();

await writeFileContent(join(testDir, "AGENTS.md"), "# Project Overview\n");
await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API Instructions\n");
// Vendored and generated trees must stay out of the scan.
await writeFileContent(join(testDir, "node_modules", "dep", "AGENTS.md"), "# Vendored\n");
await writeFileContent(join(testDir, ".agents", "AGENTS.md"), "# Tool output\n");

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

const importedNested = await readFileContent(
join(testDir, ".rulesync", "rules", "packages-api.md"),
);
expect(importedNested).toContain("API Instructions");
expect(importedNested).toContain("subprojectPath: packages/api");
expect(await fileExists(join(testDir, ".rulesync", "rules", "node_modules-dep.md"))).toBe(
false,
);

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

expect(await readFileContent(join(testDir, "packages", "api", "AGENTS.md"))).toContain(
"API Instructions",
);
});
});

const rulesGlobalTargets = [
Expand Down
Loading
Loading