From a623de1f053340416d4e13d58e245b1968135365 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 01:23:00 -0700 Subject: [PATCH 1/6] fix(agentsmd): import nested AGENTS.md files and stop competing for .agents/skills/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing divergences from the AGENTS.md standard. Nested AGENTS.md files were silently dropped on import. They are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins — and export already honored them via `agentsmd.subprojectPath`, but import enumerated only the root file and `.agents/memories/**`. A project with `packages/api/AGENTS.md` imported one rule and lost the other without a word. `AgentsMdRule` now exposes `getNestedFileGlobs`, a new optional hook the RulesProcessor uses to enumerate rule files by pattern rather than at a fixed path. The scan skips hidden directories (other tools' generated output, including rulesync's own) and `node_modules/`, and is import-only: a nested file rulesync did not write must never be swept up by `--delete`. Each match imports to `.rulesync/rules/.md` carrying `subprojectPath`, so the next generate puts it back where it came from. `fromFile` also stops treating a modular file literally named `AGENTS.md` under `.agents/memories/` as the project root file, which made it read the wrong file entirely. `.agents/skills/` was written by both `agentsmd` and `agentsskills`. That path is not an AGENTS.md convention at all — the standard defines only `AGENTS.md` — it is the Agent Skills project location. Both targets resolved to the same file, so `--targets agentsmd,agentsskills` and `--targets agentsskills,agentsmd` produced different content: the simulated writer kept only name/description and dropped `license`, `compatibility`, `metadata` and `allowed-tools`. The simulated writer now emits exactly what the native one emits, through the same `toSpecConformantAgentSkillFields` helper, so enabling both writes one identical file instead of two competing ones. Removing the convention outright was the other option, but `agentsmd` is the only simulated-skills target, so that would have left `--simulate-skills` with nothing to do. Closes #2428 Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 4 + skills/rulesync/file-formats.md | 4 + src/e2e/e2e-rules.spec.ts | 30 ++++++ src/features/rules/agentsmd-rule.test.ts | 80 ++++++++++++++++ src/features/rules/agentsmd-rule.ts | 104 +++++++++++++++++++-- src/features/rules/rules-processor.ts | 41 ++++++++ src/features/skills/agentsmd-skill.test.ts | 36 +++++++ src/features/skills/agentsmd-skill.ts | 20 +++- 8 files changed, 308 insertions(+), 11 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 5b434143a..aaa869e16 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -674,6 +674,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.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) and `node_modules/` are skipped, and the scan is import-only: a nested file rulesync did not write is never removed by `--delete`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. See . + +> **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. + > **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//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). diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 39a4af909..383df1048 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -674,6 +674,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.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) and `node_modules/` are skipped, and the scan is import-only: a nested file rulesync did not write is never removed by `--delete`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. See . + +> **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. + > **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//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). diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 78da01a07..6d05e3c94 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -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 = [ diff --git a/src/features/rules/agentsmd-rule.test.ts b/src/features/rules/agentsmd-rule.test.ts index 3d6b69254..468fb27f0 100644 --- a/src/features/rules/agentsmd-rule.test.ts +++ b/src/features/rules/agentsmd-rule.test.ts @@ -305,6 +305,86 @@ describe("AgentsMdRule", () => { }); }); + describe("nested AGENTS.md files", () => { + it("should glob nested files while excluding the root file, hidden dirs and node_modules", () => { + const globs = AgentsMdRule.getNestedFileGlobs({ outputRoot: "/project" }); + + expect(globs).toEqual([ + "/project/**/AGENTS.md", + "!/project/AGENTS.md", + "!/project/**/.*/**", + "!/project/**/node_modules/**", + ]); + }); + + it("should import a nested AGENTS.md as a non-root rule scoped to its directory", async () => { + const subprojectDir = join(testDir, "packages", "api"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# API\n\nAPI instructions."); + + const rule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join("packages", "api"), + relativeFilePath: "AGENTS.md", + }); + + expect(rule.isRoot()).toBe(false); + expect(rule.getSubprojectPath()).toBe("packages/api"); + expect(rule.getFileContent()).toBe("# API\n\nAPI instructions."); + }); + + it("should round-trip the subproject scope through the rulesync rule", async () => { + const subprojectDir = join(testDir, "packages", "api"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# API\n\nAPI instructions."); + + const rulesyncRule = ( + await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join("packages", "api"), + relativeFilePath: "AGENTS.md", + }) + ).toRulesyncRule(); + + // Every nested file is named AGENTS.md, so the rulesync file is named + // after the directory it scopes. + expect(rulesyncRule.getRelativeFilePath()).toBe("packages-api.md"); + expect(rulesyncRule.getFrontmatter()).toMatchObject({ + root: false, + globs: ["packages/api/**/*"], + agentsmd: { subprojectPath: "packages/api" }, + }); + + // Back out to the same place on the next generate. + const regenerated = AgentsMdRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + expect(regenerated.getRelativeDirPath()).toBe(join("packages", "api")); + expect(regenerated.getRelativeFilePath()).toBe("AGENTS.md"); + }); + + it("should not treat the project root file or a memories file as a subproject", async () => { + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + const memoriesDir = join(testDir, ".agents", "memories"); + await ensureDir(memoriesDir); + await writeFileContent(join(memoriesDir, "AGENTS.md"), "# Memory"); + + const rootRule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "AGENTS.md", + }); + expect(rootRule.isRoot()).toBe(true); + expect(rootRule.getSubprojectPath()).toBeUndefined(); + + const memoryRule = await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: join(".agents", "memories"), + relativeFilePath: "AGENTS.md", + }); + expect(memoryRule.isRoot()).toBe(false); + expect(memoryRule.getSubprojectPath()).toBeUndefined(); + }); + }); + describe("validate", () => { it("should always return success for any content", () => { const rule = new AgentsMdRule({ diff --git a/src/features/rules/agentsmd-rule.ts b/src/features/rules/agentsmd-rule.ts index f6c29c284..39cc1f1ca 100644 --- a/src/features/rules/agentsmd-rule.ts +++ b/src/features/rules/agentsmd-rule.ts @@ -5,8 +5,9 @@ import { AGENTSMD_MEMORIES_DIR_PATH, AGENTSMD_RULE_FILE_NAME, } from "../../constants/agentsmd-paths.js"; +import { RULESYNC_RULES_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; -import { readFileContent } from "../../utils/file.js"; +import { readFileContent, toPosixPath } from "../../utils/file.js"; import { RulesyncRule } from "./rulesync-rule.js"; import { ToolRule, @@ -31,6 +32,15 @@ export type AgentsMdRuleSettablePaths = Omit & { }; }; +/** + * Directory names never scanned for nested `AGENTS.md` files. Hidden directories + * are excluded wholesale because an `AGENTS.md` inside one is another tool's + * generated output (rulesync writes several itself), not a subproject; the rest + * are dependency and build trees whose vendored `AGENTS.md` files describe + * somebody else's project. + */ +const NESTED_SCAN_EXCLUDED_GLOBS = ["**/.*/**", "**/node_modules/**"]; + export class AgentsMdRule extends ToolRule { constructor({ fileContent, root, ...rest }: AgentsMdRuleParams) { super({ @@ -57,23 +67,77 @@ export class AgentsMdRule extends ToolRule { }; } + /** + * Globs for the nested `AGENTS.md` files that are the standard's only scoping + * mechanism — "Agents automatically read the nearest file in the directory + * tree, so the closest one takes precedence and every subproject can ship + * tailored instructions." The project root file is excluded because it is + * enumerated separately as the root rule. + * + * Import-only. The matches are hand-authored files anywhere in the tree rather + * than files under a rulesync-owned directory, so enumerating them for + * `--delete` would sweep away work rulesync never wrote. + * + * @see https://agents.md/ + */ + static getNestedFileGlobs({ outputRoot }: { outputRoot: string }): string[] { + const root = toPosixPath(outputRoot); + return [ + `${root}/**/${AGENTSMD_RULE_FILE_NAME}`, + `!${root}/${AGENTSMD_RULE_FILE_NAME}`, + ...NESTED_SCAN_EXCLUDED_GLOBS.map((glob) => `!${root}/${glob}`), + ]; + } + + /** + * The subproject directory this rule scopes, or `undefined` for the project + * root file and for the modular `.agents/memories/` files. + */ + getSubprojectPath(): string | undefined { + if (this.isRoot() || this.getRelativeFilePath() !== AGENTSMD_RULE_FILE_NAME) { + return undefined; + } + const relativeDirPath = toPosixPath(this.getRelativeDirPath()); + if (relativeDirPath === "." || relativeDirPath === "" || relativeDirPath.startsWith(".")) { + return undefined; + } + return relativeDirPath; + } + static async fromFile({ outputRoot = process.cwd(), + relativeDirPath, relativeFilePath, validate = true, }: ToolRuleFromFileParams): Promise { - // Determine if it's a root file based on path - const isRoot = relativeFilePath === AGENTSMD_RULE_FILE_NAME; - const relativePath = isRoot - ? AGENTSMD_RULE_FILE_NAME - : join(AGENTSMD_MEMORIES_DIR_PATH, relativeFilePath); + // A nested subproject file is an `AGENTS.md` somewhere other than the project + // root and outside the tool's own `.agents/` tree. + const normalizedDirPath = relativeDirPath === undefined ? "." : toPosixPath(relativeDirPath); + const isNested = + relativeFilePath === AGENTSMD_RULE_FILE_NAME && + normalizedDirPath !== "." && + normalizedDirPath !== "" && + !normalizedDirPath.startsWith("."); + // Only the file at the project root is the root rule. A modular file that + // happens to be named `AGENTS.md` under `.agents/memories/` is not. + const isRoot = + !isNested && + relativeFilePath === AGENTSMD_RULE_FILE_NAME && + (normalizedDirPath === "." || normalizedDirPath === ""); + const relativePath = isNested + ? join(normalizedDirPath, relativeFilePath) + : isRoot + ? AGENTSMD_RULE_FILE_NAME + : join(AGENTSMD_MEMORIES_DIR_PATH, relativeFilePath); const fileContent = await readFileContent(join(outputRoot, relativePath)); return new AgentsMdRule({ outputRoot, - relativeDirPath: isRoot - ? this.getSettablePaths().root.relativeDirPath - : this.getSettablePaths().nonRoot.relativeDirPath, + relativeDirPath: isNested + ? normalizedDirPath + : isRoot + ? this.getSettablePaths().root.relativeDirPath + : this.getSettablePaths().nonRoot.relativeDirPath, relativeFilePath: isRoot ? AGENTSMD_RULE_FILE_NAME : relativeFilePath, fileContent, validate, @@ -115,7 +179,27 @@ export class AgentsMdRule extends ToolRule { } toRulesyncRule(): RulesyncRule { - return this.toRulesyncRuleDefault(); + const subprojectPath = this.getSubprojectPath(); + if (subprojectPath === undefined) { + return this.toRulesyncRuleDefault(); + } + + // Every nested file is named `AGENTS.md`, so the rulesync file is named after + // the directory it scopes; `subprojectPath` sends it back to the same place + // on the next generate. + return new RulesyncRule({ + outputRoot: process.cwd(), + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: `${subprojectPath.replaceAll("/", "-")}.md`, + frontmatter: { + root: false, + targets: ["*"], + description: this.getDescription(), + globs: [`${subprojectPath}/**/*`], + agentsmd: { subprojectPath }, + }, + body: this.getFileContent(), + }); } validate(): ValidationResult { diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 3a0cfdab6..a0f0973fd 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -245,6 +245,15 @@ type ToolRuleFactory = { * are cleaned up when no rule targets them. See {@link PiRule.getExtraFixedFiles}. */ getExtraFixedFiles?(params: { global?: boolean }): ToolRuleExtraFixedFile[]; + /** + * Globs for rule files this tool discovers by pattern rather than at a fixed + * path, used when the tool's scoping mechanism is the same file name repeated + * in subdirectories (the AGENTS.md standard's nested files). Import-only: + * the matches are hand-authored files outside any rulesync-owned directory, + * so enumerating them for `--delete` would sweep away work rulesync never + * wrote. See {@link AgentsMdRule.getNestedFileGlobs}. + */ + getNestedFileGlobs?(params: { outputRoot: string; global?: boolean }): string[]; }; meta: { /** File extension for the rule file */ @@ -1626,6 +1635,37 @@ As this project's AI coding tool, you must follow the additional conventions bel })(); this.logger.debug(`Found ${extraFixedToolRules.length} extra fixed tool rule files`); + // Pattern-discovered rule files (the AGENTS.md standard's nested + // subproject files). Import only — see `getNestedFileGlobs`. + const nestedToolRules = await (async () => { + const globs = factory.class.getNestedFileGlobs?.({ + outputRoot: this.outputRoot, + global: this.global, + }); + if (forDeletion || !globs || globs.length === 0) { + return []; + } + + const filePaths = await findFilesByGlobs(globs, { type: "file" }); + + return await Promise.all( + filePaths.map((filePath) => { + const relativeDirPath = resolveRelativeDirPath(filePath); + checkPathTraversal({ + relativePath: relativeDirPath, + intendedRootDir: this.outputRoot, + }); + return factory.class.fromFile({ + outputRoot: this.outputRoot, + relativeDirPath, + relativeFilePath: basename(filePath), + global: this.global, + }); + }), + ); + })(); + this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`); + const nonRootToolRules = await (async () => { if (!settablePaths.nonRoot) { return []; @@ -1697,6 +1737,7 @@ As this project's AI coding tool, you must follow the additional conventions bel ...localRootToolRules, ...rootMirrorDeletionRules, ...extraFixedToolRules, + ...nestedToolRules, ...nonRootToolRules, ]; } catch (error) { diff --git a/src/features/skills/agentsmd-skill.test.ts b/src/features/skills/agentsmd-skill.test.ts index 41427faf0..d439e8fbf 100644 --- a/src/features/skills/agentsmd-skill.test.ts +++ b/src/features/skills/agentsmd-skill.test.ts @@ -7,6 +7,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { AgentsmdSkill } from "./agentsmd-skill.js"; +import { AgentsSkillsSkill } from "./agentsskills-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; describe("AgentsmdSkill", () => { @@ -125,6 +126,41 @@ This is the body of the agentsmd skill.`; description: "Test skill description", }); }); + + it("should emit the same frontmatter as the native writer that owns .agents/skills/", () => { + // Both targets resolve to `.agents/skills//SKILL.md`, so whichever + // runs last must not change the file or drop the Agent Skills fields. + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "test-skill", + frontmatter: { + name: "test-skill", + description: "Test skill description", + agentsskills: { + license: "Apache-2.0", + compatibility: "Requires Python 3.14+ and uv", + metadata: { version: 1 }, + "allowed-tools": ["Read", "Bash(git:*)"], + }, + }, + body: "Test body content", + validate: true, + }); + + const agentsmdSkill = AgentsmdSkill.fromRulesyncSkill({ rulesyncSkill }); + const agentsSkillsSkill = AgentsSkillsSkill.fromRulesyncSkill({ rulesyncSkill }); + + expect(agentsmdSkill.getFrontmatter()).toEqual(agentsSkillsSkill.getFrontmatter()); + expect(agentsmdSkill.getFrontmatter()).toEqual({ + name: "test-skill", + description: "Test skill description", + license: "Apache-2.0", + compatibility: "Requires Python 3.14+ and uv", + metadata: { version: "1" }, + "allowed-tools": "Read Bash(git:*)", + }); + }); }); describe("isTargetedByRulesyncSkill", () => { diff --git a/src/features/skills/agentsmd-skill.ts b/src/features/skills/agentsmd-skill.ts index 3a1a8dac5..41cf94eee 100644 --- a/src/features/skills/agentsmd-skill.ts +++ b/src/features/skills/agentsmd-skill.ts @@ -1,4 +1,5 @@ import { AGENTSMD_SKILLS_DIR_PATH } from "../../constants/agentsmd-paths.js"; +import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; import { SimulatedSkill, SimulatedSkillParams } from "./simulated-skill.js"; import { @@ -12,6 +13,16 @@ import { * Represents a simulated skill for AGENTS.md. * Since AGENTS.md doesn't have native skill support, this provides * a compatible skill directory format at .agents/skills/. + * + * `.agents/skills/` is not an AGENTS.md convention — the standard defines only + * `AGENTS.md` itself. It is the Agent Skills standard's project location, which + * the native `agentsskills` target writes to as well, so both targets resolve to + * the same file. To keep that harmless, this writer emits exactly the frontmatter + * `AgentsSkillsSkill` emits: whichever target runs last, the file on disk is the + * same, and the standard's optional fields are not dropped. + * + * @see https://agents.md/ + * @see https://agentskills.io/specification */ export class AgentsmdSkill extends SimulatedSkill { static getSettablePaths(options?: { global?: boolean }): ToolSkillSettablePaths { @@ -29,9 +40,16 @@ export class AgentsmdSkill extends SimulatedSkill { } static fromRulesyncSkill(params: ToolSkillFromRulesyncSkillParams): AgentsmdSkill { + const defaults = this.fromRulesyncSkillDefault(params); const baseParams: SimulatedSkillParams = { - ...this.fromRulesyncSkillDefault(params), + ...defaults, relativeDirPath: this.getSettablePaths().relativeDirPath, + frontmatter: { + ...defaults.frontmatter, + // Same shared block, same normalization as the native target that owns + // this path, so the two writers cannot disagree about the file. + ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills), + }, }; return new AgentsmdSkill(baseParams); } From 3afb6059c16512fa3ed126b37d5c53c4d3fb0873 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 01:35:33 -0700 Subject: [PATCH 2/6] fix(agentsmd): do not follow symlinks when scanning for nested AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review of the nested-file scan. The scan walks the whole project tree, and `findFilesByGlobs` follows symbolic links by default while returning the unresolved path — so `checkPathTraversal`, which is purely lexical, saw an in-project path and let it through. A repository could commit `docs/AGENTS.md` as a symlink to `../../../.ssh/id_rsa`; importing it copied the key into `.rulesync/rules/docs.md`, which this tool's whole design expects the user to commit. Confirmed by reproducing it with the repo's own helpers. The same default also made two directory symlinks pointing at a shared parent explode the traversal until the process ran out of heap. The scan now passes `followSymbolicLinks: false`, which drops both the symlinked file and the symlinked directory (verified). Fixed-path scans are unaffected — they only ever look inside rulesync-owned directories. Also from the review: - The root file's exclusion never worked. globby rewrites a negative pattern containing no glob metacharacter as cwd-relative, so `!` + an absolute path silently matches nothing. The hook now returns `{ include, ignore }` and `findFilesByGlobs` gained an `ignore` option that goes straight to globby, which has no such rewriting. The test no longer asserts on the pattern strings — it runs them against a real tree, which is what would have caught this. - The exclusion set covers dependency, vendoring and build trees beyond `node_modules/` (`vendor`, `third_party`, `dist`, `build`, `out`, `target`, `coverage`, `tmp`, `temp`, `venv`, `__pycache__`). Those are usually gitignored, so importing from them moves content the user deliberately kept out of the repository into a tracked directory. - Two directories deriving the same rulesync file name (`packages/api` and `packages-api`) are reported at import time instead of one silently overwriting the other. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- src/features/rules/agentsmd-rule.test.ts | 61 +++++++++++++++++++----- src/features/rules/agentsmd-rule.ts | 46 +++++++++++++----- src/features/rules/rules-processor.ts | 47 +++++++++++++++--- src/features/rules/tool-rule.ts | 15 ++++++ src/utils/file.ts | 15 +++++- 7 files changed, 153 insertions(+), 35 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index aaa869e16..0d9af2626 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -674,7 +674,7 @@ 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.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) and `node_modules/` are skipped, and the scan is import-only: a nested file rulesync did not write is never removed by `--delete`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. See . +> **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, as are dependency, vendoring and build trees (`node_modules/`, `vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`, `__pycache__/`) — an `AGENTS.md` there describes somebody else's project. 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`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. Two directories that derive the same name (`packages/api` and `packages-api`) are reported at import time, since only the last one survives. See . > **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 383df1048..bd83db565 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -674,7 +674,7 @@ 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.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) and `node_modules/` are skipped, and the scan is import-only: a nested file rulesync did not write is never removed by `--delete`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. See . +> **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, as are dependency, vendoring and build trees (`node_modules/`, `vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`, `__pycache__/`) — an `AGENTS.md` there describes somebody else's project. 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`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. Two directories that derive the same name (`packages/api` and `packages-api`) are reported at import time, since only the last one survives. See . > **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. diff --git a/src/features/rules/agentsmd-rule.test.ts b/src/features/rules/agentsmd-rule.test.ts index 468fb27f0..a55b9f38e 100644 --- a/src/features/rules/agentsmd-rule.test.ts +++ b/src/features/rules/agentsmd-rule.test.ts @@ -1,10 +1,11 @@ -import { join } from "node:path"; +import { rm, symlink } from "node:fs/promises"; +import { basename, join, relative } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; -import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { ensureDir, findFilesByGlobs, toPosixPath, writeFileContent } from "../../utils/file.js"; import { AgentsMdRule } from "./agentsmd-rule.js"; import { RulesyncRule } from "./rulesync-rule.js"; @@ -306,15 +307,53 @@ describe("AgentsMdRule", () => { }); describe("nested AGENTS.md files", () => { - it("should glob nested files while excluding the root file, hidden dirs and node_modules", () => { - const globs = AgentsMdRule.getNestedFileGlobs({ outputRoot: "/project" }); - - expect(globs).toEqual([ - "/project/**/AGENTS.md", - "!/project/AGENTS.md", - "!/project/**/.*/**", - "!/project/**/node_modules/**", - ]); + it("should match only nested subproject files, against a real tree", async () => { + // Asserting on the returned patterns alone would not catch a pattern that + // silently matches nothing, so run them against actual files. + for (const relativePath of [ + "AGENTS.md", + join("packages", "api", "AGENTS.md"), + join("packages", "api", "src", "AGENTS.md"), + join("node_modules", "dep", "AGENTS.md"), + join("vendor", "lib", "AGENTS.md"), + join("dist", "AGENTS.md"), + join(".agents", "AGENTS.md"), + join(".agents", "memories", "AGENTS.md"), + ]) { + await writeFileContent(join(testDir, relativePath), "# rule"); + } + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect( + matched.map((filePath) => toPosixPath(relative(testDir, filePath))).toSorted(), + ).toEqual(["packages/api/AGENTS.md", "packages/api/src/AGENTS.md"]); + }); + + it("should not follow symlinks out of the project", async () => { + // A repository can commit a symlink, so following one would copy a file + // from outside the project into version-controlled `.rulesync/rules/`. + const outsideDir = join(testDir, "..", `outside-${basename(testDir)}`); + await ensureDir(outsideDir); + await writeFileContent(join(outsideDir, "secret.md"), "SECRET"); + await ensureDir(join(testDir, "docs")); + await symlink(join(outsideDir, "secret.md"), join(testDir, "docs", "AGENTS.md")); + await symlink(outsideDir, join(testDir, "linked")); + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect(matched).toEqual([]); + await rm(outsideDir, { recursive: true, force: true }); }); it("should import a nested AGENTS.md as a non-root rule scoped to its directory", async () => { diff --git a/src/features/rules/agentsmd-rule.ts b/src/features/rules/agentsmd-rule.ts index 39cc1f1ca..a9b027e45 100644 --- a/src/features/rules/agentsmd-rule.ts +++ b/src/features/rules/agentsmd-rule.ts @@ -14,6 +14,7 @@ import { ToolRuleForDeletionParams, ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, + ToolRuleNestedFilePatterns, ToolRuleSettablePaths, buildToolPath, } from "./tool-rule.js"; @@ -33,13 +34,28 @@ export type AgentsMdRuleSettablePaths = Omit & { }; /** - * Directory names never scanned for nested `AGENTS.md` files. Hidden directories - * are excluded wholesale because an `AGENTS.md` inside one is another tool's - * generated output (rulesync writes several itself), not a subproject; the rest - * are dependency and build trees whose vendored `AGENTS.md` files describe - * somebody else's project. + * Directories never scanned for nested `AGENTS.md` files. Hidden directories are + * excluded because an `AGENTS.md` inside one is another tool's generated output + * (rulesync writes several itself), not a subproject. The rest are dependency, + * vendoring and build trees: an `AGENTS.md` there describes somebody else's + * project, and is usually gitignored, so importing it would move content the + * user deliberately kept out of the repository into version-controlled + * `.rulesync/rules/`. */ -const NESTED_SCAN_EXCLUDED_GLOBS = ["**/.*/**", "**/node_modules/**"]; +const NESTED_SCAN_EXCLUDED_DIRS = [ + "node_modules", + "vendor", + "third_party", + "dist", + "build", + "out", + "target", + "coverage", + "tmp", + "temp", + "venv", + "__pycache__", +]; export class AgentsMdRule extends ToolRule { constructor({ fileContent, root, ...rest }: AgentsMdRuleParams) { @@ -68,7 +84,7 @@ export class AgentsMdRule extends ToolRule { } /** - * Globs for the nested `AGENTS.md` files that are the standard's only scoping + * Patterns for the nested `AGENTS.md` files that are the standard's only scoping * mechanism — "Agents automatically read the nearest file in the directory * tree, so the closest one takes precedence and every subproject can ship * tailored instructions." The project root file is excluded because it is @@ -80,13 +96,17 @@ export class AgentsMdRule extends ToolRule { * * @see https://agents.md/ */ - static getNestedFileGlobs({ outputRoot }: { outputRoot: string }): string[] { + static getNestedFilePatterns({ outputRoot }: { outputRoot: string }): ToolRuleNestedFilePatterns { const root = toPosixPath(outputRoot); - return [ - `${root}/**/${AGENTSMD_RULE_FILE_NAME}`, - `!${root}/${AGENTSMD_RULE_FILE_NAME}`, - ...NESTED_SCAN_EXCLUDED_GLOBS.map((glob) => `!${root}/${glob}`), - ]; + return { + include: [`${root}/**/${AGENTSMD_RULE_FILE_NAME}`], + ignore: [ + // Enumerated separately as the root rule. + `${root}/${AGENTSMD_RULE_FILE_NAME}`, + `${root}/**/.*/**`, + ...NESTED_SCAN_EXCLUDED_DIRS.map((dir) => `${root}/**/${dir}/**`), + ], + }; } /** diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index a0f0973fd..79864cb1e 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -71,6 +71,7 @@ import { ToolRuleForDeletionParams, ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, + ToolRuleNestedFilePatterns, ToolRuleSettablePaths, ToolRuleSettablePathsGlobal, } from "./tool-rule.js"; @@ -246,14 +247,17 @@ type ToolRuleFactory = { */ getExtraFixedFiles?(params: { global?: boolean }): ToolRuleExtraFixedFile[]; /** - * Globs for rule files this tool discovers by pattern rather than at a fixed + * Patterns for rule files this tool discovers by glob rather than at a fixed * path, used when the tool's scoping mechanism is the same file name repeated * in subdirectories (the AGENTS.md standard's nested files). Import-only: * the matches are hand-authored files outside any rulesync-owned directory, * so enumerating them for `--delete` would sweep away work rulesync never - * wrote. See {@link AgentsMdRule.getNestedFileGlobs}. + * wrote. See {@link AgentsMdRule.getNestedFilePatterns}. */ - getNestedFileGlobs?(params: { outputRoot: string; global?: boolean }): string[]; + getNestedFilePatterns?(params: { + outputRoot: string; + global?: boolean; + }): ToolRuleNestedFilePatterns; }; meta: { /** File extension for the rule file */ @@ -1638,17 +1642,26 @@ As this project's AI coding tool, you must follow the additional conventions bel // Pattern-discovered rule files (the AGENTS.md standard's nested // subproject files). Import only — see `getNestedFileGlobs`. const nestedToolRules = await (async () => { - const globs = factory.class.getNestedFileGlobs?.({ + const patterns = factory.class.getNestedFilePatterns?.({ outputRoot: this.outputRoot, global: this.global, }); - if (forDeletion || !globs || globs.length === 0) { + if (forDeletion || !patterns || patterns.include.length === 0) { return []; } - const filePaths = await findFilesByGlobs(globs, { type: "file" }); + // Symlinks are not followed. Unlike the fixed-path scans, this one walks + // the whole project tree, so a symlink committed to a repository could + // otherwise pull a file from outside the project (a key, a dotfile) into + // version-controlled `.rulesync/rules/`. Not following them also keeps a + // pair of directory symlinks from exploding the traversal. + const filePaths = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); - return await Promise.all( + const rules = await Promise.all( filePaths.map((filePath) => { const relativeDirPath = resolveRelativeDirPath(filePath); checkPathTraversal({ @@ -1663,6 +1676,26 @@ As this project's AI coding tool, you must follow the additional conventions bel }); }), ); + + // Every nested file shares one file name, so the rulesync name is derived + // from the directory. Two directories can still derive the same name + // (`packages/api` and `packages-api`), and the later import would + // overwrite the earlier one without a word. + const claimedBy = new Map(); + for (const rule of rules) { + const source = join(rule.getRelativeDirPath(), rule.getRelativeFilePath()); + const rulesyncName = rule.toRulesyncRule().getRelativeFilePath(); + const previous = claimedBy.get(rulesyncName); + if (previous === undefined) { + claimedBy.set(rulesyncName, source); + } else { + this.logger.warn( + `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, rulesyncName)}; only the last one is kept.`, + ); + } + } + + return rules; })(); this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`); diff --git a/src/features/rules/tool-rule.ts b/src/features/rules/tool-rule.ts index dff34388b..3f467d32b 100644 --- a/src/features/rules/tool-rule.ts +++ b/src/features/rules/tool-rule.ts @@ -44,6 +44,21 @@ export type ToolRuleExtraFixedFile = { relativeFilePath: string; }; +/** + * Glob patterns for rule files a tool discovers by pattern rather than at a + * fixed path (the AGENTS.md standard's nested subproject files). Returned by the + * optional static `getNestedFilePatterns` hook and consumed by the + * RulesProcessor on import. + * + * `ignore` is separate from `include` rather than expressed as `!` patterns + * because globby rewrites a negative pattern containing no glob metacharacter as + * cwd-relative, which makes an absolute one silently match nothing. + */ +export type ToolRuleNestedFilePatterns = { + include: string[]; + ignore: string[]; +}; + export type ToolRuleSettablePaths = { root?: { relativeDirPath: string; diff --git a/src/utils/file.ts b/src/utils/file.ts index 6afd1df9e..bbaa69209 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -320,9 +320,19 @@ export async function findFiles(dir: string, extension: string = ".md"): Promise export async function findFilesByGlobs( globs: string | string[], - options: { type?: "file" | "dir" | "all"; followSymbolicLinks?: boolean } = {}, + options: { + type?: "file" | "dir" | "all"; + followSymbolicLinks?: boolean; + /** + * Patterns to exclude, passed to globby's `ignore`. Prefer this over inline + * `!` patterns: globby rewrites a negative pattern that contains no glob + * metacharacter as cwd-relative, so an absolute `!/abs/path/file.md` silently + * matches nothing. + */ + ignore?: string[]; + } = {}, ): Promise { - const { type = "all", followSymbolicLinks = true } = options; + const { type = "all", followSymbolicLinks = true, ignore } = options; const globbyOptions = type === "file" ? { onlyFiles: true, onlyDirectories: false } @@ -340,6 +350,7 @@ export async function findFilesByGlobs( const results = globbySync(normalizedGlobs, { absolute: true, followSymbolicLinks, + ...(ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {}), ...globbyOptions, }); // Deduplicate by real path so that directory symlink cycles (which globby follows up to From bc6e7d564fc30b193e6b89a899281abe1c3f5f81 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 01:49:00 -0700 Subject: [PATCH 3/6] fix(agentsmd): stop nested imports from clobbering the root rule, and cover every .agents/skills/ writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review round 1. HIGH — an `overview/` subproject claimed the reserved `overview.md` name, so its content overwrote the imported root rule. The root rule then no longer existed, and the next `generate --delete` removed the project's `AGENTS.md` outright. A subproject whose derived name would be `overview.md` now gets an `-agents` suffix, and the duplicate check moved from "nested versus nested" to every rule being written, so a collision with a `.agents/memories/` file or a hand-written rule is reported too. MID — the exclusion list dropped real subprojects. `build`, `dist`, `vendor`, `tmp` and friends were excluded at any depth, so `packages/build/AGENTS.md` — a package, not a build directory — vanished from the import without a word. Those names are now excluded at the project root only; `node_modules` and `__pycache__`, which are never package names, stay excluded at any depth. MID — `.agents/skills/` has four writers, not two. `aiassistant` and `codexcli` also target it and also emitted only `name`/`description`, so the same order-dependent frontmatter loss this PR set out to fix was still reachable via `--targets agentsskills,aiassistant`. Both now go through `toSpecConformantAgentSkillFields`; Codex's own `short-description` metadata is merged into the shared map rather than replacing it. MID (recorded, not fixed) — `--delete` leaves nested `AGENTS.md` files behind, so a deleted rulesync rule stops being referenced from the root file while the subproject file keeps being read. Deleting them is not an option: they are the user's own files anywhere in the tree, which is exactly why the scan is import-only. Warning instead would need a full-tree scan on every generate, for a condition that predates this PR (it already applied to hand-written `subprojectPath` rules). Documented as a stated limitation instead. LOW — the nested-file note moved from the skills section to the rules section; the `## Symlinks` section now records that this one scan is the exception; `getNestedFilePatterns` is skipped entirely in global mode, where the output root is the home directory; `fromFile` stores the directory with native separators like every other construction path; and the duplicate check no longer converts each rule twice. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 6 ++- skills/rulesync/file-formats.md | 6 ++- src/features/rules/agentsmd-rule.test.ts | 43 +++++++++++++++++ src/features/rules/agentsmd-rule.ts | 46 ++++++++++++------ src/features/rules/rules-processor.test.ts | 33 +++++++++++++ src/features/rules/rules-processor.ts | 55 ++++++++++++---------- src/features/skills/aiassistant-skill.ts | 6 +++ src/features/skills/codexcli-skill.ts | 14 +++++- 8 files changed, 163 insertions(+), 46 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 0d9af2626..e0ac1585b 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -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: @@ -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. 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/.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. See . + > **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. @@ -674,8 +678,6 @@ 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.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, as are dependency, vendoring and build trees (`node_modules/`, `vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`, `__pycache__/`) — an `AGENTS.md` there describes somebody else's project. 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`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. Two directories that derive the same name (`packages/api` and `packages-api`) are reported at import time, since only the last one survives. See . - > **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. > **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. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index bd83db565..f6e378185 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -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: @@ -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. 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/.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. See . + > **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. @@ -674,8 +678,6 @@ 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.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, as are dependency, vendoring and build trees (`node_modules/`, `vendor/`, `third_party/`, `dist/`, `build/`, `out/`, `target/`, `coverage/`, `tmp/`, `temp/`, `venv/`, `__pycache__/`) — an `AGENTS.md` there describes somebody else's project. 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`. Each discovered file is imported to `.rulesync/rules/.md` (e.g. `packages/api/AGENTS.md` → `packages-api.md`) carrying `agentsmd.subprojectPath`, so the next generate puts it back where it came from. Two directories that derive the same name (`packages/api` and `packages-api`) are reported at import time, since only the last one survives. See . - > **`.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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. > **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. diff --git a/src/features/rules/agentsmd-rule.test.ts b/src/features/rules/agentsmd-rule.test.ts index a55b9f38e..7fe7f6308 100644 --- a/src/features/rules/agentsmd-rule.test.ts +++ b/src/features/rules/agentsmd-rule.test.ts @@ -400,6 +400,49 @@ describe("AgentsMdRule", () => { expect(regenerated.getRelativeFilePath()).toBe("AGENTS.md"); }); + it("should exclude build directories only at the project root", async () => { + // A top-level `build/` is a build directory; `packages/build/` is a package. + for (const relativePath of [ + join("build", "AGENTS.md"), + join("packages", "build", "AGENTS.md"), + join("packages", "app", "node_modules", "dep", "AGENTS.md"), + ]) { + await writeFileContent(join(testDir, relativePath), "# rule"); + } + + const patterns = AgentsMdRule.getNestedFilePatterns({ outputRoot: testDir }); + const matched = await findFilesByGlobs(patterns.include, { + type: "file", + followSymbolicLinks: false, + ignore: patterns.ignore, + }); + + expect(matched.map((filePath) => toPosixPath(relative(testDir, filePath)))).toEqual([ + "packages/build/AGENTS.md", + ]); + }); + + it("should not claim the reserved overview.md name for an `overview` subproject", async () => { + // Overwriting overview.md would drop the root rule, and the next + // `--delete` would then remove the root AGENTS.md too. + const subprojectDir = join(testDir, "overview"); + await ensureDir(subprojectDir); + await writeFileContent(join(subprojectDir, "AGENTS.md"), "# Overview subproject"); + + const rulesyncRule = ( + await AgentsMdRule.fromFile({ + outputRoot: testDir, + relativeDirPath: "overview", + relativeFilePath: "AGENTS.md", + }) + ).toRulesyncRule(); + + expect(rulesyncRule.getRelativeFilePath()).toBe("overview-agents.md"); + expect(rulesyncRule.getFrontmatter()).toMatchObject({ + agentsmd: { subprojectPath: "overview" }, + }); + }); + it("should not treat the project root file or a memories file as a subproject", async () => { await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); const memoriesDir = join(testDir, ".agents", "memories"); diff --git a/src/features/rules/agentsmd-rule.ts b/src/features/rules/agentsmd-rule.ts index a9b027e45..5301a37e8 100644 --- a/src/features/rules/agentsmd-rule.ts +++ b/src/features/rules/agentsmd-rule.ts @@ -5,7 +5,10 @@ import { AGENTSMD_MEMORIES_DIR_PATH, AGENTSMD_RULE_FILE_NAME, } from "../../constants/agentsmd-paths.js"; -import { RULESYNC_RULES_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { + RULESYNC_OVERVIEW_FILE_NAME, + RULESYNC_RULES_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { readFileContent, toPosixPath } from "../../utils/file.js"; import { RulesyncRule } from "./rulesync-rule.js"; @@ -34,16 +37,20 @@ export type AgentsMdRuleSettablePaths = Omit & { }; /** - * Directories never scanned for nested `AGENTS.md` files. Hidden directories are - * excluded because an `AGENTS.md` inside one is another tool's generated output - * (rulesync writes several itself), not a subproject. The rest are dependency, - * vendoring and build trees: an `AGENTS.md` there describes somebody else's - * project, and is usually gitignored, so importing it would move content the - * user deliberately kept out of the repository into version-controlled - * `.rulesync/rules/`. + * Dependency trees never scanned for nested `AGENTS.md` files, at any depth. An + * `AGENTS.md` there describes somebody else's project, and neither name is ever + * a package name. Hidden directories are excluded separately, because an + * `AGENTS.md` inside one is another tool's generated output (rulesync writes + * several itself). + */ +const NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH = ["node_modules", "__pycache__"]; + +/** + * Build, vendoring and scratch directories, excluded at the **project root + * only**. A top-level `build/` is a build directory; `packages/build/` is a + * package, and dropping it silently would lose a real subproject. */ -const NESTED_SCAN_EXCLUDED_DIRS = [ - "node_modules", +const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [ "vendor", "third_party", "dist", @@ -54,7 +61,6 @@ const NESTED_SCAN_EXCLUDED_DIRS = [ "tmp", "temp", "venv", - "__pycache__", ]; export class AgentsMdRule extends ToolRule { @@ -104,7 +110,8 @@ export class AgentsMdRule extends ToolRule { // Enumerated separately as the root rule. `${root}/${AGENTSMD_RULE_FILE_NAME}`, `${root}/**/.*/**`, - ...NESTED_SCAN_EXCLUDED_DIRS.map((dir) => `${root}/**/${dir}/**`), + ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`), + ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`), ], }; } @@ -153,8 +160,10 @@ export class AgentsMdRule extends ToolRule { return new AgentsMdRule({ outputRoot, + // `join` so the stored path uses native separators like every other + // construction path (`fromRulesyncRule` builds it the same way). relativeDirPath: isNested - ? normalizedDirPath + ? join(normalizedDirPath) : isRoot ? this.getSettablePaths().root.relativeDirPath : this.getSettablePaths().nonRoot.relativeDirPath, @@ -206,11 +215,18 @@ export class AgentsMdRule extends ToolRule { // Every nested file is named `AGENTS.md`, so the rulesync file is named after // the directory it scopes; `subprojectPath` sends it back to the same place - // on the next generate. + // on the next generate. A subproject that would claim the reserved root-rule + // name gets a suffix instead: overwriting `overview.md` would drop the root + // rule entirely, and the next `--delete` would then remove the root + // `AGENTS.md` along with it. + const derivedName = `${subprojectPath.replaceAll("/", "-")}.md`; return new RulesyncRule({ outputRoot: process.cwd(), relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, - relativeFilePath: `${subprojectPath.replaceAll("/", "-")}.md`, + relativeFilePath: + derivedName === RULESYNC_OVERVIEW_FILE_NAME + ? `${subprojectPath.replaceAll("/", "-")}-agents.md` + : derivedName, frontmatter: { root: false, targets: ["*"], diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 5208591c4..72e6b1d73 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -686,6 +686,39 @@ describe("RulesProcessor", () => { expect(claudecodePaths).toContain(join("backend", "api-rule.md")); }); + it("should discover nested AGENTS.md files on import but never for deletion", async () => { + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + + const imported = await processor.loadToolFiles(); + expect( + imported.map((file) => join(file.getRelativeDirPath(), file.getRelativeFilePath())), + ).toContain(join("packages", "api", "AGENTS.md")); + + // A nested file rulesync did not write must never become a deletion + // candidate — it is the user's own file, anywhere in the tree. + const forDeletion = await processor.loadToolFiles({ forDeletion: true }); + expect( + forDeletion.map((file) => join(file.getRelativeDirPath(), file.getRelativeFilePath())), + ).not.toContain(join("packages", "api", "AGENTS.md")); + }); + + it("should warn when two rule files import to the same rulesync file name", async () => { + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent(join(testDir, "packages-api", "AGENTS.md"), "# Also API"); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + await processor.convertToolFilesToRulesyncFiles(await processor.loadToolFiles()); + + expect( + logger.warn.mock.calls.some(([message]) => + String(message).includes(join(RULESYNC_RULES_RELATIVE_DIR_PATH, "packages-api.md")), + ), + ).toBe(true); + }); + it("should load CLAUDE.md from .claude/ directory when only .claude/CLAUDE.md exists", async () => { await ensureDir(join(testDir, ".claude")); await writeFileContent(join(testDir, ".claude", "CLAUDE.md"), "# Project from .claude dir"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 79864cb1e..e12028254 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -1324,6 +1324,27 @@ As this project's AI coding tool, you must follow the additional conventions bel return toolRule.toRulesyncRule(); }); + // Several tool files can derive the same rulesync file name — most easily + // with the AGENTS.md standard's nested files, where every source is named + // `AGENTS.md` and the rulesync name comes from the directory. The writer + // overwrites, so without this the earlier rule disappears silently. + const claimedBy = new Map(); + for (const [index, rulesyncRule] of rulesyncRules.entries()) { + const target = rulesyncRule.getRelativeFilePath(); + const source = join( + toolRules[index]!.getRelativeDirPath(), + toolRules[index]!.getRelativeFilePath(), + ); + const previous = claimedBy.get(target); + if (previous === undefined) { + claimedBy.set(target, source); + continue; + } + this.logger.warn( + `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)}; only the last one is kept.`, + ); + } + return rulesyncRules; } @@ -1642,10 +1663,14 @@ As this project's AI coding tool, you must follow the additional conventions bel // Pattern-discovered rule files (the AGENTS.md standard's nested // subproject files). Import only — see `getNestedFileGlobs`. const nestedToolRules = await (async () => { - const patterns = factory.class.getNestedFilePatterns?.({ - outputRoot: this.outputRoot, - global: this.global, - }); + // Never in global mode: the output root is the home directory there, and + // walking all of it looking for subprojects is both wrong and expensive. + const patterns = this.global + ? undefined + : factory.class.getNestedFilePatterns?.({ + outputRoot: this.outputRoot, + global: this.global, + }); if (forDeletion || !patterns || patterns.include.length === 0) { return []; } @@ -1661,7 +1686,7 @@ As this project's AI coding tool, you must follow the additional conventions bel ignore: patterns.ignore, }); - const rules = await Promise.all( + return await Promise.all( filePaths.map((filePath) => { const relativeDirPath = resolveRelativeDirPath(filePath); checkPathTraversal({ @@ -1676,26 +1701,6 @@ As this project's AI coding tool, you must follow the additional conventions bel }); }), ); - - // Every nested file shares one file name, so the rulesync name is derived - // from the directory. Two directories can still derive the same name - // (`packages/api` and `packages-api`), and the later import would - // overwrite the earlier one without a word. - const claimedBy = new Map(); - for (const rule of rules) { - const source = join(rule.getRelativeDirPath(), rule.getRelativeFilePath()); - const rulesyncName = rule.toRulesyncRule().getRelativeFilePath(); - const previous = claimedBy.get(rulesyncName); - if (previous === undefined) { - claimedBy.set(rulesyncName, source); - } else { - this.logger.warn( - `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, rulesyncName)}; only the last one is kept.`, - ); - } - } - - return rules; })(); this.logger.debug(`Found ${nestedToolRules.length} nested tool rule files`); diff --git a/src/features/skills/aiassistant-skill.ts b/src/features/skills/aiassistant-skill.ts index 958f12e9c..2f5ade23d 100644 --- a/src/features/skills/aiassistant-skill.ts +++ b/src/features/skills/aiassistant-skill.ts @@ -7,6 +7,7 @@ import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; +import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; import { ToolSkill, @@ -153,6 +154,11 @@ export class AiassistantSkill extends ToolSkill { const aiassistantFrontmatter: AiassistantSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, + // This target writes to `.agents/skills/`, the Agent Skills project + // location that `agentsskills` also owns, so it emits the same normalized + // shared block. Otherwise whichever target ran last decided whether the + // standard's optional fields survived. + ...toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills), }; return new AiassistantSkill({ diff --git a/src/features/skills/codexcli-skill.ts b/src/features/skills/codexcli-skill.ts index f7475e309..86910859a 100644 --- a/src/features/skills/codexcli-skill.ts +++ b/src/features/skills/codexcli-skill.ts @@ -13,6 +13,7 @@ import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { toPosixPath } from "../../utils/file.js"; import { loadYaml } from "../../utils/yaml.js"; +import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill, RulesyncSkillFrontmatter, @@ -268,12 +269,21 @@ export class CodexCliSkill extends ToolSkill { const settablePaths = CodexCliSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + // This target writes to `.agents/skills/`, the Agent Skills project location + // that `agentsskills` also owns, so it emits the same normalized shared + // block. Otherwise whichever target ran last decided whether the standard's + // optional fields survived. + const sharedFields = toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills); + const shortDescription = rulesyncFrontmatter.codexcli?.["short-description"]; const codexFrontmatter: CodexCliSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - ...(rulesyncFrontmatter.codexcli?.["short-description"] && { + ...sharedFields, + // Codex's own metadata key is merged into, not over, the shared map. + ...((sharedFields.metadata || shortDescription) && { metadata: { - "short-description": rulesyncFrontmatter.codexcli["short-description"], + ...sharedFields.metadata, + ...(shortDescription && { "short-description": shortDescription }), }, }), }; From 9cb244029db482a2fc233dd7e62a932fe9107b9f Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 02:08:31 -0700 Subject: [PATCH 4/6] fix(agentsmd): respect .gitignore in the nested scan; narrow the skills fix to the simulated writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security re-review: - The nested scan honors `.gitignore` (`findFilesByGlobs` gained `cwd` and `gitignore` options). Narrowing the build/vendor exclusions to the project root reopened a real path: a gitignored `services/api/vendor/` could carry a third-party `AGENTS.md` into version-controlled `.rulesync/rules/`, where a target that concatenates non-root rules into one always-loaded file would then apply it project-wide. Git's own statement of what is not your source is a better rule than any name list, and it keeps `packages/build/` — the case the name list was narrowed for. Ignore rules resolve from the enclosing repository, so the nested tests now create a `.git` directory: without one, this repo's ignored `tmp/` hides the whole test project. Code re-review: - The reserved-name guard and the duplicate check are case-insensitive. On a case-insensitive filesystem an `Overview/` subproject derived `Overview.md`, which is the root rule's `overview.md` — the same silent root-rule loss the previous commit fixed for the lowercase spelling, and the duplicate check did not catch it either. - The shared-frontmatter normalization is reverted for `aiassistant` and `codexcli`. Nine targets write `.agents/skills/`, and eight of them are native with their own documented frontmatter block; feeding them the `agentsskills` block made them write fields their own `toRulesyncSkill` does not read, so generate → import became lossy where it had been symmetric. Which rulesync block should feed a file that many native targets share is a design decision, not a bug fix, and a partial answer is worse than none. `agentsmd` stays fixed, because it is not the same case: its skills support is *simulated* — the AGENTS.md standard defines no skills — so it has no frontmatter of its own to contribute and was purely degrading a native target's output. "A simulated writer must not degrade a path a native target owns" is the rule this PR enforces. The docs now state the general situation. - Tests: the reserved-name guard on a mixed-case directory, the no-collision case (guarding against false-positive warnings), and `logger.warn.mockClear()` so the shared mock cannot make an assertion pass by accident. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 6 ++- skills/rulesync/file-formats.md | 6 ++- src/e2e/e2e-rules.spec.ts | 5 +- src/features/rules/agentsmd-rule.ts | 9 ++-- src/features/rules/rules-processor.test.ts | 60 ++++++++++++++++++++++ src/features/rules/rules-processor.ts | 23 +++++---- src/features/skills/aiassistant-skill.ts | 6 --- src/features/skills/codexcli-skill.ts | 14 +---- src/utils/file.ts | 15 +++++- 9 files changed, 107 insertions(+), 37 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index e0ac1585b..07318d7e2 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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. 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/.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. See . +> **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 are resolved from the enclosing repository, so this is a no-op outside git.) 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/.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 . > **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`. @@ -678,7 +678,9 @@ 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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. +> **`.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. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index f6e378185..af8d6afc9 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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. 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/.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. See . +> **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 are resolved from the enclosing repository, so this is a no-op outside git.) 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/.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 . > **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`. @@ -678,7 +678,9 @@ 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. The simulated `agentsmd` skills writer targets the same path, so it emits exactly the frontmatter `agentsskills` emits: enabling both targets writes one identical file instead of two competing ones, and the order of `--targets` no longer decides whether the standard's optional fields survive. +> **`.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. diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 6d05e3c94..8babc9914 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -9,7 +9,7 @@ import { RULESYNC_RULES_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; import { RulesProcessor } from "../features/rules/rules-processor.js"; -import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -781,6 +781,9 @@ This is a test project for E2E testing. it("should import nested agentsmd rules and round-trip their subproject scope", async () => { const testDir = getTestDir(); + // The nested scan respects .gitignore, so the test project needs to be its + // own repository root — otherwise this repo's ignored `tmp/` hides it. + await ensureDir(join(testDir, ".git")); 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. diff --git a/src/features/rules/agentsmd-rule.ts b/src/features/rules/agentsmd-rule.ts index 5301a37e8..0ced2eb84 100644 --- a/src/features/rules/agentsmd-rule.ts +++ b/src/features/rules/agentsmd-rule.ts @@ -219,13 +219,16 @@ export class AgentsMdRule extends ToolRule { // name gets a suffix instead: overwriting `overview.md` would drop the root // rule entirely, and the next `--delete` would then remove the root // `AGENTS.md` along with it. - const derivedName = `${subprojectPath.replaceAll("/", "-")}.md`; + // Compared case-insensitively: on a case-insensitive filesystem an + // `Overview/` subproject would otherwise still land on the root rule's file. + const slug = subprojectPath.replaceAll("/", "-"); + const derivedName = `${slug}.md`; return new RulesyncRule({ outputRoot: process.cwd(), relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, relativeFilePath: - derivedName === RULESYNC_OVERVIEW_FILE_NAME - ? `${subprojectPath.replaceAll("/", "-")}-agents.md` + derivedName.toLowerCase() === RULESYNC_OVERVIEW_FILE_NAME.toLowerCase() + ? `${slug}-agents.md` : derivedName, frontmatter: { root: false, diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 72e6b1d73..f2bea1ac4 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -687,6 +687,9 @@ describe("RulesProcessor", () => { }); it("should discover nested AGENTS.md files on import but never for deletion", async () => { + // The nested scan respects .gitignore, so the test project needs to be its + // own repository root — otherwise this repo's ignored `tmp/` hides it. + await ensureDir(join(testDir, ".git")); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -705,10 +708,35 @@ describe("RulesProcessor", () => { ).not.toContain(join("packages", "api", "AGENTS.md")); }); + it("should skip nested AGENTS.md files the project gitignores", async () => { + // A vendored dependency's rule file is third-party content the user + // deliberately kept untracked; importing it would copy it into + // version-controlled `.rulesync/rules/`. + await ensureDir(join(testDir, ".git")); + await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n"); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent( + join(testDir, "services", "api", "vendor", "dep", "AGENTS.md"), + "# Vendored", + ); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const files = await processor.loadToolFiles(); + const paths = files.map((file) => + join(file.getRelativeDirPath(), file.getRelativeFilePath()), + ); + + expect(paths).toContain(join("packages", "api", "AGENTS.md")); + expect(paths).not.toContain(join("services", "api", "vendor", "dep", "AGENTS.md")); + }); + it("should warn when two rule files import to the same rulesync file name", async () => { + await ensureDir(join(testDir, ".git")); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); await writeFileContent(join(testDir, "packages-api", "AGENTS.md"), "# Also API"); + logger.warn.mockClear(); const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); await processor.convertToolFilesToRulesyncFiles(await processor.loadToolFiles()); @@ -719,6 +747,38 @@ describe("RulesProcessor", () => { ).toBe(true); }); + it("should not warn when every rule file maps to a distinct rulesync name", async () => { + await ensureDir(join(testDir, ".git")); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent(join(testDir, "packages", "web", "AGENTS.md"), "# Web"); + await writeFileContent(join(testDir, ".agents", "memories", "extra.md"), "# Extra"); + + logger.warn.mockClear(); + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + await processor.convertToolFilesToRulesyncFiles(await processor.loadToolFiles()); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("should keep an `Overview` subproject away from the reserved root-rule name", async () => { + // Case-insensitive filesystems would otherwise resolve `Overview.md` and + // the root rule's `overview.md` to the same file. + await ensureDir(join(testDir, ".git")); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "Overview", "AGENTS.md"), "# Overview subproject"); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const rulesyncFiles = await processor.convertToolFilesToRulesyncFiles( + await processor.loadToolFiles(), + ); + + expect(rulesyncFiles.map((file) => file.getRelativeFilePath()).toSorted()).toEqual([ + "Overview-agents.md", + "overview.md", + ]); + }); + it("should load CLAUDE.md from .claude/ directory when only .claude/CLAUDE.md exists", async () => { await ensureDir(join(testDir, ".claude")); await writeFileContent(join(testDir, ".claude", "CLAUDE.md"), "# Project from .claude dir"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index e12028254..e48061e17 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -254,10 +254,7 @@ type ToolRuleFactory = { * so enumerating them for `--delete` would sweep away work rulesync never * wrote. See {@link AgentsMdRule.getNestedFilePatterns}. */ - getNestedFilePatterns?(params: { - outputRoot: string; - global?: boolean; - }): ToolRuleNestedFilePatterns; + getNestedFilePatterns?(params: { outputRoot: string }): ToolRuleNestedFilePatterns; }; meta: { /** File extension for the rule file */ @@ -1328,6 +1325,8 @@ As this project's AI coding tool, you must follow the additional conventions bel // with the AGENTS.md standard's nested files, where every source is named // `AGENTS.md` and the rulesync name comes from the directory. The writer // overwrites, so without this the earlier rule disappears silently. + // Keyed case-insensitively, because on a case-insensitive filesystem + // `Docs.md` and `docs.md` are one file. const claimedBy = new Map(); for (const [index, rulesyncRule] of rulesyncRules.entries()) { const target = rulesyncRule.getRelativeFilePath(); @@ -1335,9 +1334,9 @@ As this project's AI coding tool, you must follow the additional conventions bel toolRules[index]!.getRelativeDirPath(), toolRules[index]!.getRelativeFilePath(), ); - const previous = claimedBy.get(target); + const previous = claimedBy.get(target.toLowerCase()); if (previous === undefined) { - claimedBy.set(target, source); + claimedBy.set(target.toLowerCase(), source); continue; } this.logger.warn( @@ -1667,10 +1666,7 @@ As this project's AI coding tool, you must follow the additional conventions bel // walking all of it looking for subprojects is both wrong and expensive. const patterns = this.global ? undefined - : factory.class.getNestedFilePatterns?.({ - outputRoot: this.outputRoot, - global: this.global, - }); + : factory.class.getNestedFilePatterns?.({ outputRoot: this.outputRoot }); if (forDeletion || !patterns || patterns.include.length === 0) { return []; } @@ -1684,6 +1680,13 @@ As this project's AI coding tool, you must follow the additional conventions bel type: "file", followSymbolicLinks: false, ignore: patterns.ignore, + // `.gitignore` is the project's own statement of what is not its + // source. Without it a vendored dependency's rule file — third-party + // content the user deliberately kept untracked — would be copied into + // version-controlled `.rulesync/rules/`, and targets that concatenate + // non-root rules into one file would then load it unconditionally. + cwd: this.outputRoot, + gitignore: true, }); return await Promise.all( diff --git a/src/features/skills/aiassistant-skill.ts b/src/features/skills/aiassistant-skill.ts index 2f5ade23d..958f12e9c 100644 --- a/src/features/skills/aiassistant-skill.ts +++ b/src/features/skills/aiassistant-skill.ts @@ -7,7 +7,6 @@ import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; -import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; import { ToolSkill, @@ -154,11 +153,6 @@ export class AiassistantSkill extends ToolSkill { const aiassistantFrontmatter: AiassistantSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - // This target writes to `.agents/skills/`, the Agent Skills project - // location that `agentsskills` also owns, so it emits the same normalized - // shared block. Otherwise whichever target ran last decided whether the - // standard's optional fields survived. - ...toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills), }; return new AiassistantSkill({ diff --git a/src/features/skills/codexcli-skill.ts b/src/features/skills/codexcli-skill.ts index 86910859a..f7475e309 100644 --- a/src/features/skills/codexcli-skill.ts +++ b/src/features/skills/codexcli-skill.ts @@ -13,7 +13,6 @@ import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { toPosixPath } from "../../utils/file.js"; import { loadYaml } from "../../utils/yaml.js"; -import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill, RulesyncSkillFrontmatter, @@ -269,21 +268,12 @@ export class CodexCliSkill extends ToolSkill { const settablePaths = CodexCliSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); - // This target writes to `.agents/skills/`, the Agent Skills project location - // that `agentsskills` also owns, so it emits the same normalized shared - // block. Otherwise whichever target ran last decided whether the standard's - // optional fields survived. - const sharedFields = toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills); - const shortDescription = rulesyncFrontmatter.codexcli?.["short-description"]; const codexFrontmatter: CodexCliSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - ...sharedFields, - // Codex's own metadata key is merged into, not over, the shared map. - ...((sharedFields.metadata || shortDescription) && { + ...(rulesyncFrontmatter.codexcli?.["short-description"] && { metadata: { - ...sharedFields.metadata, - ...(shortDescription && { "short-description": shortDescription }), + "short-description": rulesyncFrontmatter.codexcli["short-description"], }, }), }; diff --git a/src/utils/file.ts b/src/utils/file.ts index bbaa69209..0d6da3e6e 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -328,11 +328,22 @@ export async function findFilesByGlobs( * `!` patterns: globby rewrites a negative pattern that contains no glob * metacharacter as cwd-relative, so an absolute `!/abs/path/file.md` silently * matches nothing. + * + * Match the form of the include patterns: when those are absolute, a + * relative `ignore` such as `dist/**` silently excludes nothing. Either use + * absolute ignore patterns or anchor them with a leading `**\/`. */ ignore?: string[]; + /** Base directory for `gitignore` resolution and relative patterns. */ + cwd?: string; + /** + * Skip files excluded by the project's `.gitignore` files, resolved from + * `cwd`. A no-op outside a git repository. + */ + gitignore?: boolean; } = {}, ): Promise { - const { type = "all", followSymbolicLinks = true, ignore } = options; + const { type = "all", followSymbolicLinks = true, ignore, cwd, gitignore } = options; const globbyOptions = type === "file" ? { onlyFiles: true, onlyDirectories: false } @@ -351,6 +362,8 @@ export async function findFilesByGlobs( absolute: true, followSymbolicLinks, ...(ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {}), + ...(cwd ? { cwd } : {}), + ...(gitignore ? { gitignore } : {}), ...globbyOptions, }); // Deduplicate by real path so that directory symlink cycles (which globby follows up to From c16045b452559de930ade2070ea3424a075838ee Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 02:22:19 -0700 Subject: [PATCH 5/6] fix(agentsmd): test ignored directories, not ignored files, in the nested scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3. MID — `gitignore: true` on the glob collided head-on with rulesync's own tooling. `rulesync gitignore` derives `**/AGENTS.md` from the agentsmd root path, and this repository's `.gitignore` has exactly that line; a file-level ignore test therefore excluded every nested match, so this PR's whole feature returned zero results for any project that ran the recommended command — and said nothing but a debug line. The check now runs against the *directories* above each file (`filterOutPathsInGitIgnoredDirectories`), which still skips a vendored tree such as `services/api/vendor/` while surviving a pattern aimed at the generated file name. `findFilesByGlobs` loses the `gitignore`/`cwd` options again; the shared util should not carry a knob only one caller can use safely. LOW — the "no-op outside git" claim was wrong: globby falls back to the directory's own `.gitignore` files when there is no repository, so the docs now say so. The collision warning no longer claims one file always wins, since the comparison is case-insensitive while a case-sensitive filesystem keeps both. The `.git` test setup moved into one `asRepositoryRoot` helper, and the docs paragraph that had escaped its blockquote is back inside it. Tests: the new util is covered directly (ignored directory, a rule matching the files themselves, and a project with no rules), plus a processor-level regression for the `**/AGENTS.md` case that motivated this commit. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 4 +- skills/rulesync/file-formats.md | 4 +- src/features/rules/rules-processor.test.ts | 42 ++++++++++++++--- src/features/rules/rules-processor.ts | 28 ++++++++---- src/utils/file.test.ts | 39 ++++++++++++++++ src/utils/file.ts | 53 +++++++++++++++++----- 6 files changed, 138 insertions(+), 32 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 07318d7e2..3280859fd 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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 are resolved from the enclosing repository, so this is a no-op outside git.) 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/.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 . +> **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 are read from the enclosing repository, or from the project's own `.gitignore` files when it is not in one. 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.) 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/.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 . > **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`. @@ -680,7 +680,7 @@ When `claudecode.scheduled-task: true` is set, that skill is emitted only as a C > **`.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. +> 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. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index af8d6afc9..4540e2fa5 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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 are resolved from the enclosing repository, so this is a no-op outside git.) 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/.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 . +> **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 are read from the enclosing repository, or from the project's own `.gitignore` files when it is not in one. 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.) 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/.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 . > **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`. @@ -680,7 +680,7 @@ When `claudecode.scheduled-task: true` is set, that skill is emitted only as a C > **`.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. +> 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. diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index f2bea1ac4..8dcfa8e75 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -29,6 +29,15 @@ const globalFoldTargets = RulesProcessor.getToolTargets({ global: true }).filter (target) => RulesProcessor.getFactory(target)?.meta.foldsNonRootIntoRoot === true, ); +/** + * The nested-AGENTS.md scan resolves ignore rules from the enclosing repository, + * so a test project needs to look like its own repository root — otherwise this + * repo's ignored `tmp/` hides the whole thing. + */ +const asRepositoryRoot = async (dir: string): Promise => { + await ensureDir(join(dir, ".git")); +}; + describe("RulesProcessor", () => { let testDir: string; let cleanup: () => Promise; @@ -687,9 +696,7 @@ describe("RulesProcessor", () => { }); it("should discover nested AGENTS.md files on import but never for deletion", async () => { - // The nested scan respects .gitignore, so the test project needs to be its - // own repository root — otherwise this repo's ignored `tmp/` hides it. - await ensureDir(join(testDir, ".git")); + await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -712,7 +719,7 @@ describe("RulesProcessor", () => { // A vendored dependency's rule file is third-party content the user // deliberately kept untracked; importing it would copy it into // version-controlled `.rulesync/rules/`. - await ensureDir(join(testDir, ".git")); + await asRepositoryRoot(testDir); await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n"); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -731,8 +738,29 @@ describe("RulesProcessor", () => { expect(paths).not.toContain(join("services", "api", "vendor", "dep", "AGENTS.md")); }); + it("should still find nested files when .gitignore excludes the generated file name", async () => { + // `rulesync gitignore` writes `**/AGENTS.md` for its own output, so a + // file-level ignore test would silently disable the whole scan. + await asRepositoryRoot(testDir); + await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n**/AGENTS.md\n"); + await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); + await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); + await writeFileContent( + join(testDir, "services", "api", "vendor", "dep", "AGENTS.md"), + "# Vendored", + ); + + const processor = new RulesProcessor({ logger, outputRoot: testDir, toolTarget: "agentsmd" }); + const paths = (await processor.loadToolFiles()).map((file) => + join(file.getRelativeDirPath(), file.getRelativeFilePath()), + ); + + expect(paths).toContain(join("packages", "api", "AGENTS.md")); + expect(paths).not.toContain(join("services", "api", "vendor", "dep", "AGENTS.md")); + }); + it("should warn when two rule files import to the same rulesync file name", async () => { - await ensureDir(join(testDir, ".git")); + await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); await writeFileContent(join(testDir, "packages-api", "AGENTS.md"), "# Also API"); @@ -748,7 +776,7 @@ describe("RulesProcessor", () => { }); it("should not warn when every rule file maps to a distinct rulesync name", async () => { - await ensureDir(join(testDir, ".git")); + await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); await writeFileContent(join(testDir, "packages", "web", "AGENTS.md"), "# Web"); @@ -764,7 +792,7 @@ describe("RulesProcessor", () => { it("should keep an `Overview` subproject away from the reserved root-rule name", async () => { // Case-insensitive filesystems would otherwise resolve `Overview.md` and // the root rule's `overview.md` to the same file. - await ensureDir(join(testDir, ".git")); + await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "Overview", "AGENTS.md"), "# Overview subproject"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index e48061e17..981dda3f7 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -15,7 +15,12 @@ import { ToolFile } from "../../types/tool-file.js"; import { rulesProcessorToolTargetTuple } from "../../types/tool-target-tuples.js"; import { ToolTarget } from "../../types/tool-targets.js"; import { formatError } from "../../utils/error.js"; -import { checkPathTraversal, findFilesByGlobs, toPosixPath } from "../../utils/file.js"; +import { + checkPathTraversal, + filterOutPathsInGitIgnoredDirectories, + findFilesByGlobs, + toPosixPath, +} from "../../utils/file.js"; import type { Logger } from "../../utils/logger.js"; import { AgentsmdCommand } from "../commands/agentsmd-command.js"; import { CommandsProcessor } from "../commands/commands-processor.js"; @@ -1340,7 +1345,7 @@ As this project's AI coding tool, you must follow the additional conventions bel continue; } this.logger.warn( - `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)}; only the last one is kept.`, + `Both ${previous} and ${source} import to ${join(RULESYNC_RULES_RELATIVE_DIR_PATH, target)} (compared case-insensitively, as on macOS and Windows); the last one wins wherever they collide.`, ); } @@ -1676,17 +1681,20 @@ As this project's AI coding tool, you must follow the additional conventions bel // otherwise pull a file from outside the project (a key, a dotfile) into // version-controlled `.rulesync/rules/`. Not following them also keeps a // pair of directory symlinks from exploding the traversal. - const filePaths = await findFilesByGlobs(patterns.include, { + const matchedPaths = await findFilesByGlobs(patterns.include, { type: "file", followSymbolicLinks: false, ignore: patterns.ignore, - // `.gitignore` is the project's own statement of what is not its - // source. Without it a vendored dependency's rule file — third-party - // content the user deliberately kept untracked — would be copied into - // version-controlled `.rulesync/rules/`, and targets that concatenate - // non-root rules into one file would then load it unconditionally. - cwd: this.outputRoot, - gitignore: true, + }); + + // The project's own statement of what is not its source. Without it a + // vendored dependency's rule file — third-party content the user + // deliberately kept untracked — would be copied into version-controlled + // `.rulesync/rules/`, and targets that concatenate non-root rules into + // one file would then load it unconditionally. + const filePaths = filterOutPathsInGitIgnoredDirectories({ + rootDir: this.outputRoot, + filePaths: matchedPaths, }); return await Promise.all( diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index 29691ebab..dd38058d5 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -17,6 +17,7 @@ import { createPathResolver, directoryExists, ensureDir, + filterOutPathsInGitIgnoredDirectories, fileExists, findFiles, findFilesByGlobs, @@ -382,6 +383,44 @@ describe("file utilities", () => { }); }); + describe("filterOutPathsInGitIgnoredDirectories", () => { + it("should drop files inside an ignored directory but keep the rest", async () => { + await writeFileContent(join(testDir, ".gitignore"), "vendored/\n"); + await ensureDir(join(testDir, ".git")); + const kept = join(testDir, "packages", "api", "AGENTS.md"); + const dropped = join(testDir, "vendored", "dep", "AGENTS.md"); + await writeFileContent(kept, "keep"); + await writeFileContent(dropped, "drop"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [kept, dropped] }), + ).toEqual([kept]); + }); + + it("should ignore a rule that matches the files themselves", async () => { + // `rulesync gitignore` writes `**/AGENTS.md` for its own output; testing + // the files rather than their directories would disable every scan. + await writeFileContent(join(testDir, ".gitignore"), "**/AGENTS.md\n"); + await ensureDir(join(testDir, ".git")); + const filePath = join(testDir, "packages", "api", "AGENTS.md"); + await writeFileContent(filePath, "keep"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [filePath] }), + ).toEqual([filePath]); + }); + + it("should keep everything when the project has no ignore rules", async () => { + await ensureDir(join(testDir, ".git")); + const filePath = join(testDir, "packages", "api", "AGENTS.md"); + await writeFileContent(filePath, "keep"); + + expect( + filterOutPathsInGitIgnoredDirectories({ rootDir: testDir, filePaths: [filePath] }), + ).toEqual([filePath]); + }); + }); + describe("fileExists", () => { it("should return true for existing file", async () => { const filePath = join(testDir, "exists.txt"); diff --git a/src/utils/file.ts b/src/utils/file.ts index 0d6da3e6e..1d16ab714 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -14,7 +14,7 @@ import os from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { kebabCase } from "es-toolkit"; -import { globbySync } from "globby"; +import { globbySync, isGitIgnoredSync } from "globby"; import { formatError } from "./error.js"; import { isEnvTest } from "./vitest.js"; @@ -143,6 +143,46 @@ export async function ensureDir(dirPath: string): Promise { } } +/** + * Drop paths that sit inside a directory the project's git ignore rules exclude. + * + * Deliberately tests the **directories** above each file rather than the file + * itself. A project that ran `rulesync gitignore` has patterns for rulesync's + * own outputs — `**\/AGENTS.md` among them — so a file-level test would exclude + * every match and quietly disable the scan. What this is for is skipping + * vendored and generated *trees*: content the project deliberately does not + * track, which must not be copied into version-controlled rulesync sources. + * + * Ignore rules resolve from `rootDir` (its enclosing repository if it is inside + * one, otherwise its own `.gitignore` files). + */ +export function filterOutPathsInGitIgnoredDirectories({ + rootDir, + filePaths, +}: { + rootDir: string; + filePaths: string[]; +}): string[] { + const isIgnored = isGitIgnoredSync({ cwd: rootDir }); + const resolvedRoot = resolve(rootDir); + const cache = new Map(); + + const isInIgnoredDirectory = (directory: string): boolean => { + const cached = cache.get(directory); + if (cached !== undefined) { + return cached; + } + // The trailing slash is what makes a `vendored/` rule match the directory. + const ignored = + directory !== resolvedRoot && + (isIgnored(`${toPosixPath(directory)}/`) || isInIgnoredDirectory(dirname(directory))); + cache.set(directory, ignored); + return ignored; + }; + + return filePaths.filter((filePath) => !isInIgnoredDirectory(dirname(resolve(filePath)))); +} + /** * Converts OS-native path separators to POSIX forward slashes. * Use this instead of `path.posix.join` when input segments may already @@ -334,16 +374,9 @@ export async function findFilesByGlobs( * absolute ignore patterns or anchor them with a leading `**\/`. */ ignore?: string[]; - /** Base directory for `gitignore` resolution and relative patterns. */ - cwd?: string; - /** - * Skip files excluded by the project's `.gitignore` files, resolved from - * `cwd`. A no-op outside a git repository. - */ - gitignore?: boolean; } = {}, ): Promise { - const { type = "all", followSymbolicLinks = true, ignore, cwd, gitignore } = options; + const { type = "all", followSymbolicLinks = true, ignore } = options; const globbyOptions = type === "file" ? { onlyFiles: true, onlyDirectories: false } @@ -362,8 +395,6 @@ export async function findFilesByGlobs( absolute: true, followSymbolicLinks, ...(ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {}), - ...(cwd ? { cwd } : {}), - ...(gitignore ? { gitignore } : {}), ...globbyOptions, }); // Deduplicate by real path so that directory symlink cycles (which globby follows up to From d4d778009af40b9311001197e55b1b317c615b08 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 27 Jul 2026 02:33:37 -0700 Subject: [PATCH 6/6] fix(agentsmd): correct the gitignore scope claim and guard the ancestor walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4 — no mid-or-above findings; these are the lows. - globby's `isGitIgnoredSync` reads the `.gitignore` files at and below `cwd`; it does not search upward for a repository root. The JSDoc and docs said the opposite, and the `.git` directory the tests created to "become a repository root" was a no-op — the tests passed either way, which is the proof. Both are corrected, the `.git` setup is gone, and the docs note the consequence: a run against a subdirectory only sees that subdirectory's own ignore rules. - `filterOutPathsInGitIgnoredDirectories` recursed forever on a path outside `rootDir`, because `dirname("/")` is `"/"` and the cache was written after the recursive call. The only caller cannot reach it, but the helper is exported and `resolve()` turns a relative path into one rooted at the process cwd. Guarded, with a test. - The helper returns early for an empty input; building the matcher scans the tree for `.gitignore` files, which is not worth doing with nothing to filter. - The simulated `agentsmd` writer now reports the same spec violations the native writer reports. Emitting the same file while staying silent about it contradicted this PR's own reasoning, and `--targets agentsmd` alone got no diagnostics at all. - Documented that testing directories rather than files means ignoring one individual `AGENTS.md` no longer keeps it out of the import. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/file-formats.md | 2 +- skills/rulesync/file-formats.md | 2 +- src/e2e/e2e-rules.spec.ts | 5 +--- src/features/rules/rules-processor.test.ts | 15 ---------- src/features/skills/agentsmd-skill.test.ts | 21 ++++++++++++++ src/features/skills/agentsmd-skill.ts | 33 ++++++++++++++-------- src/features/skills/agentsskills-skill.ts | 2 +- src/utils/file.test.ts | 13 +++++++-- src/utils/file.ts | 19 ++++++++++--- 9 files changed, 71 insertions(+), 41 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 3280859fd..e3ff605ae 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -58,7 +58,7 @@ 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 are read from the enclosing repository, or from the project's own `.gitignore` files when it is not in one. 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.) 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/.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 . +> **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/.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 . > **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`. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 4540e2fa5..383410a8c 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -58,7 +58,7 @@ 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 are read from the enclosing repository, or from the project's own `.gitignore` files when it is not in one. 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.) 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/.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 . +> **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/.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 . > **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`. diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 8babc9914..6d05e3c94 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -9,7 +9,7 @@ import { RULESYNC_RULES_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; import { RulesProcessor } from "../features/rules/rules-processor.js"; -import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; import { assertGenerateMatrixCoversTargets, runGenerate, @@ -781,9 +781,6 @@ This is a test project for E2E testing. it("should import nested agentsmd rules and round-trip their subproject scope", async () => { const testDir = getTestDir(); - // The nested scan respects .gitignore, so the test project needs to be its - // own repository root — otherwise this repo's ignored `tmp/` hides it. - await ensureDir(join(testDir, ".git")); 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. diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 8dcfa8e75..1a837ecc2 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -29,15 +29,6 @@ const globalFoldTargets = RulesProcessor.getToolTargets({ global: true }).filter (target) => RulesProcessor.getFactory(target)?.meta.foldsNonRootIntoRoot === true, ); -/** - * The nested-AGENTS.md scan resolves ignore rules from the enclosing repository, - * so a test project needs to look like its own repository root — otherwise this - * repo's ignored `tmp/` hides the whole thing. - */ -const asRepositoryRoot = async (dir: string): Promise => { - await ensureDir(join(dir, ".git")); -}; - describe("RulesProcessor", () => { let testDir: string; let cleanup: () => Promise; @@ -696,7 +687,6 @@ describe("RulesProcessor", () => { }); it("should discover nested AGENTS.md files on import but never for deletion", async () => { - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -719,7 +709,6 @@ describe("RulesProcessor", () => { // A vendored dependency's rule file is third-party content the user // deliberately kept untracked; importing it would copy it into // version-controlled `.rulesync/rules/`. - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n"); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -741,7 +730,6 @@ describe("RulesProcessor", () => { it("should still find nested files when .gitignore excludes the generated file name", async () => { // `rulesync gitignore` writes `**/AGENTS.md` for its own output, so a // file-level ignore test would silently disable the whole scan. - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, ".gitignore"), "services/api/vendor/\n**/AGENTS.md\n"); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); @@ -760,7 +748,6 @@ describe("RulesProcessor", () => { }); it("should warn when two rule files import to the same rulesync file name", async () => { - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); await writeFileContent(join(testDir, "packages-api", "AGENTS.md"), "# Also API"); @@ -776,7 +763,6 @@ describe("RulesProcessor", () => { }); it("should not warn when every rule file maps to a distinct rulesync name", async () => { - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "packages", "api", "AGENTS.md"), "# API"); await writeFileContent(join(testDir, "packages", "web", "AGENTS.md"), "# Web"); @@ -792,7 +778,6 @@ describe("RulesProcessor", () => { it("should keep an `Overview` subproject away from the reserved root-rule name", async () => { // Case-insensitive filesystems would otherwise resolve `Overview.md` and // the root rule's `overview.md` to the same file. - await asRepositoryRoot(testDir); await writeFileContent(join(testDir, "AGENTS.md"), "# Root"); await writeFileContent(join(testDir, "Overview", "AGENTS.md"), "# Overview subproject"); diff --git a/src/features/skills/agentsmd-skill.test.ts b/src/features/skills/agentsmd-skill.test.ts index d439e8fbf..e5351ca21 100644 --- a/src/features/skills/agentsmd-skill.test.ts +++ b/src/features/skills/agentsmd-skill.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SKILL_FILE_NAME } from "../../constants/general.js"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { createMockLogger } from "../../test-utils/mock-logger.js"; import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { AgentsmdSkill } from "./agentsmd-skill.js"; @@ -127,6 +128,26 @@ This is the body of the agentsmd skill.`; }); }); + it("should report the same spec violations the native writer would", () => { + const logger = createMockLogger(); + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "My_Bad--Name", + frontmatter: { name: "My_Bad--Name", description: "Test skill description" }, + body: "Test body content", + validate: true, + }); + + AgentsmdSkill.fromRulesyncSkill({ rulesyncSkill, logger }); + + expect( + logger.warn.mock.calls.some(([message]) => + String(message).includes("lowercase letters, digits and single hyphens"), + ), + ).toBe(true); + }); + it("should emit the same frontmatter as the native writer that owns .agents/skills/", () => { // Both targets resolve to `.agents/skills//SKILL.md`, so whichever // runs last must not change the file or drop the Agent Skills fields. diff --git a/src/features/skills/agentsmd-skill.ts b/src/features/skills/agentsmd-skill.ts index 41cf94eee..5cbe69807 100644 --- a/src/features/skills/agentsmd-skill.ts +++ b/src/features/skills/agentsmd-skill.ts @@ -1,7 +1,7 @@ import { AGENTSMD_SKILLS_DIR_PATH } from "../../constants/agentsmd-paths.js"; -import { toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; +import { AgentsSkillsSkill, toSpecConformantAgentSkillFields } from "./agentsskills-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; -import { SimulatedSkill, SimulatedSkillParams } from "./simulated-skill.js"; +import { SimulatedSkill } from "./simulated-skill.js"; import { ToolSkillForDeletionParams, ToolSkillFromDirParams, @@ -41,17 +41,26 @@ export class AgentsmdSkill extends SimulatedSkill { static fromRulesyncSkill(params: ToolSkillFromRulesyncSkillParams): AgentsmdSkill { const defaults = this.fromRulesyncSkillDefault(params); - const baseParams: SimulatedSkillParams = { - ...defaults, - relativeDirPath: this.getSettablePaths().relativeDirPath, - frontmatter: { - ...defaults.frontmatter, - // Same shared block, same normalization as the native target that owns - // this path, so the two writers cannot disagree about the file. - ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills), - }, + const relativeDirPath = this.getSettablePaths().relativeDirPath; + const frontmatter = { + ...defaults.frontmatter, + // Same shared block, same normalization as the native target that owns + // this path, so the two writers cannot disagree about the file. + ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills), }; - return new AgentsmdSkill(baseParams); + + // Same file, same diagnostics: generating for this target alone must report + // the spec violations the native target would have reported. + AgentsSkillsSkill.reportSpecViolations({ + outputRoot: params.outputRoot ?? process.cwd(), + relativeDirPath, + dirName: params.rulesyncSkill.getDirName(), + frontmatter, + sourceAllowedTools: params.rulesyncSkill.getFrontmatter().agentsskills?.["allowed-tools"], + logger: params.logger, + }); + + return new AgentsmdSkill({ ...defaults, relativeDirPath, frontmatter }); } static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { diff --git a/src/features/skills/agentsskills-skill.ts b/src/features/skills/agentsskills-skill.ts index 3e57369c3..cd90135bd 100644 --- a/src/features/skills/agentsskills-skill.ts +++ b/src/features/skills/agentsskills-skill.ts @@ -444,7 +444,7 @@ export class AgentsSkillsSkill extends ToolSkill { * skill points at the file that actually gets written under the home * directory rather than a same-named project path. */ - protected static reportSpecViolations({ + static reportSpecViolations({ outputRoot, relativeDirPath, dirName, diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts index dd38058d5..80a7b259a 100644 --- a/src/utils/file.test.ts +++ b/src/utils/file.test.ts @@ -386,7 +386,6 @@ describe("file utilities", () => { describe("filterOutPathsInGitIgnoredDirectories", () => { it("should drop files inside an ignored directory but keep the rest", async () => { await writeFileContent(join(testDir, ".gitignore"), "vendored/\n"); - await ensureDir(join(testDir, ".git")); const kept = join(testDir, "packages", "api", "AGENTS.md"); const dropped = join(testDir, "vendored", "dep", "AGENTS.md"); await writeFileContent(kept, "keep"); @@ -401,7 +400,6 @@ describe("file utilities", () => { // `rulesync gitignore` writes `**/AGENTS.md` for its own output; testing // the files rather than their directories would disable every scan. await writeFileContent(join(testDir, ".gitignore"), "**/AGENTS.md\n"); - await ensureDir(join(testDir, ".git")); const filePath = join(testDir, "packages", "api", "AGENTS.md"); await writeFileContent(filePath, "keep"); @@ -410,8 +408,17 @@ describe("file utilities", () => { ).toEqual([filePath]); }); + it("should not recurse forever for a path outside the root", () => { + // `dirname("/")` is `"/"`, so walking ancestors would not terminate. + expect( + filterOutPathsInGitIgnoredDirectories({ + rootDir: join(testDir, "nested"), + filePaths: ["/etc/hostname"], + }), + ).toEqual(["/etc/hostname"]); + }); + it("should keep everything when the project has no ignore rules", async () => { - await ensureDir(join(testDir, ".git")); const filePath = join(testDir, "packages", "api", "AGENTS.md"); await writeFileContent(filePath, "keep"); diff --git a/src/utils/file.ts b/src/utils/file.ts index 1d16ab714..cbd97ba66 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -153,8 +153,9 @@ export async function ensureDir(dirPath: string): Promise { * vendored and generated *trees*: content the project deliberately does not * track, which must not be copied into version-controlled rulesync sources. * - * Ignore rules resolve from `rootDir` (its enclosing repository if it is inside - * one, otherwise its own `.gitignore` files). + * Ignore rules come from the `.gitignore` files at and below `rootDir`; a parent + * repository's rules are not consulted, so running against a subdirectory of a + * repository only sees that subdirectory's own rules. */ export function filterOutPathsInGitIgnoredDirectories({ rootDir, @@ -163,6 +164,12 @@ export function filterOutPathsInGitIgnoredDirectories({ rootDir: string; filePaths: string[]; }): string[] { + if (filePaths.length === 0) { + // Building the matcher scans the tree for `.gitignore` files, which is not + // worth doing when there is nothing to filter. + return filePaths; + } + const isIgnored = isGitIgnoredSync({ cwd: rootDir }); const resolvedRoot = resolve(rootDir); const cache = new Map(); @@ -172,10 +179,14 @@ export function filterOutPathsInGitIgnoredDirectories({ if (cached !== undefined) { return cached; } - // The trailing slash is what makes a `vendored/` rule match the directory. + const parent = dirname(directory); + // Stop at `rootDir`, and at the filesystem root for a path that never + // reaches it — `dirname("/")` is `"/"`, so walking up would not terminate. const ignored = directory !== resolvedRoot && - (isIgnored(`${toPosixPath(directory)}/`) || isInIgnoredDirectory(dirname(directory))); + parent !== directory && + // The trailing slash is what makes a `vendored/` rule match the directory. + (isIgnored(`${toPosixPath(directory)}/`) || isInIgnoredDirectory(parent)); cache.set(directory, ignored); return ignored; };