diff --git a/README.md b/README.md index c7f554604..16d3b2474 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | +| Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Qwen Code | qwencode | ✅ | ✅ | | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | diff --git a/cspell.json b/cspell.json index 0bdb9d46f..da1035c47 100644 --- a/cspell.json +++ b/cspell.json @@ -257,6 +257,9 @@ "stylelintcache", "sugarss", "Tabnine", + "takt", + "TAKT", + "Takt", "testignore", "textextensions", "tfstate", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 48db54e9d..f995b8071 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -25,6 +25,9 @@ antigravity: # antigravity specific parameters trigger: "always_on" # always_on, glob, manual, or model_decision globs: ["**/*"] # (optional) file patterns to match when trigger is "glob" description: "When to apply this rule" # (optional) used with "model_decision" trigger +takt: # takt specific parameters (optional, plain Markdown only — frontmatter is dropped on emit) + facet: "policy" # (optional) "policy" (default), "knowledge", or "output-contract" + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- # Rulesync Project Overview @@ -183,6 +186,8 @@ copilot: # copilot specific parameters (optional) antigravity: # antigravity specific parameters trigger: "/review" # Specific trigger for workflow (renames file to review.md) turbo: true # (Optional, default: true) Append // turbo for auto-execution +takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "instruction") + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- target_pr = $ARGUMENTS @@ -222,6 +227,8 @@ opencode: # for OpenCode-specific parameters permission: bash: "git diff": allow +takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "persona") + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- You are the planner for any tasks. @@ -253,6 +260,9 @@ claudecode: # for claudecode-specific parameters disable-model-invocation: true # (optional) disable model invocation for this skill codexcli: # for codexcli-specific parameters short-description: A brief user-facing description +takt: # takt specific parameters (optional, plain Markdown only — frontmatter is dropped on emit) + facet: "instruction" # (optional) "instruction" (default), "knowledge", or "output-contract" + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- This is the skill body content. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 7f7c04f54..275f69826 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -19,6 +19,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | +| Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Qwen Code | qwencode | ✅ | ✅ | | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | diff --git a/docs/tools/takt.md b/docs/tools/takt.md new file mode 100644 index 000000000..4cfaf1d92 --- /dev/null +++ b/docs/tools/takt.md @@ -0,0 +1,47 @@ +# Takt + +[Takt](https://github.com/dyoshikawa/takt) is a faceted-prompting AI coding workflow tool. Rulesync generates plain-Markdown facet files into Takt's `.takt/facets/` layout (or `~/.takt/facets/` in global mode). + +## Output mapping + +| Rulesync feature | Default facet directory | Allowed `takt.facet` overrides | +| ---------------- | ---------------------------- | ------------------------------------------------------- | +| `subagents` | `.takt/facets/personas/` | `persona` only (override is a no-op) | +| `rules` | `.takt/facets/policies/` | `policy` (default), `knowledge`, `output-contract` | +| `commands` | `.takt/facets/instructions/` | `instruction` only (override is a no-op) | +| `skills` | `.takt/facets/instructions/` | `instruction` (default), `knowledge`, `output-contract` | + +The facet override is read from the rulesync source frontmatter under the `takt:` key: + +```yaml +--- +takt: + facet: knowledge # rules + skills only + name: my-renamed-stem +--- +``` + +- `takt.facet` is **optional**; the per-feature default is used when absent. +- A disallowed value (for example `takt.facet: persona` on a rule) raises a hard validation error at `generate` time. +- `takt.name` is also optional and lets you rename the emitted filename stem to escape collisions. + +Output files are **plain Markdown** — the source frontmatter is dropped entirely and the body is written verbatim. The filename stem of the source is preserved unless `takt.name` is set: + +``` +.rulesync/subagents/coder.md → .takt/facets/personas/coder.md +.rulesync/rules/style.md → .takt/facets/policies/style.md +.rulesync/commands/review.md → .takt/facets/instructions/review.md +.rulesync/skills/oncall/SKILL.md → .takt/facets/instructions/oncall.md +``` + +## Scope + +Both project mode (`.takt/facets/...`) and global mode (`~/.takt/facets/...`) are supported. + +## Filename collisions + +Because commands and skills can both write to `.takt/facets/instructions/`, two source files that share a stem may collide. When this happens, Rulesync logs a warning naming both source files plus the conflicting target path and SKIPS writing both colliding files for that target — the rest of the run continues. Other (non-colliding) takt files are still written, and other targets are unaffected. Rename one of the colliding sources via `takt.name` to disambiguate. + +## Importing existing TAKT files into rulesync + +Reverse import (`rulesync import --targets takt`) is **not supported**. TAKT facet files are plain Markdown with no frontmatter, so the original skill / command / subagent metadata cannot be recovered. Attempting to import a TAKT skill raises a clear error rather than silently producing a stub that round-trips badly. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 48db54e9d..f995b8071 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -25,6 +25,9 @@ antigravity: # antigravity specific parameters trigger: "always_on" # always_on, glob, manual, or model_decision globs: ["**/*"] # (optional) file patterns to match when trigger is "glob" description: "When to apply this rule" # (optional) used with "model_decision" trigger +takt: # takt specific parameters (optional, plain Markdown only — frontmatter is dropped on emit) + facet: "policy" # (optional) "policy" (default), "knowledge", or "output-contract" + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- # Rulesync Project Overview @@ -183,6 +186,8 @@ copilot: # copilot specific parameters (optional) antigravity: # antigravity specific parameters trigger: "/review" # Specific trigger for workflow (renames file to review.md) turbo: true # (Optional, default: true) Append // turbo for auto-execution +takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "instruction") + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- target_pr = $ARGUMENTS @@ -222,6 +227,8 @@ opencode: # for OpenCode-specific parameters permission: bash: "git diff": allow +takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "persona") + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- You are the planner for any tasks. @@ -253,6 +260,9 @@ claudecode: # for claudecode-specific parameters disable-model-invocation: true # (optional) disable model invocation for this skill codexcli: # for codexcli-specific parameters short-description: A brief user-facing description +takt: # takt specific parameters (optional, plain Markdown only — frontmatter is dropped on emit) + facet: "instruction" # (optional) "instruction" (default), "knowledge", or "output-contract" + name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- This is the skill body content. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 7f7c04f54..275f69826 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -19,6 +19,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | | Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | +| Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Qwen Code | qwencode | ✅ | ✅ | | | | | | | | Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index 1cc64c843..bdca599df 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -22,10 +22,25 @@ const TARGETS_WITHOUT_GITIGNORE_ENTRIES = new Set([ ]); describe("GITIGNORE_ENTRY_REGISTRY", () => { - it("should have no duplicate entries", () => { - const entries = GITIGNORE_ENTRY_REGISTRY.map((tag) => tag.entry); - const unique = new Set(entries); - expect(entries.length).toBe(unique.size); + it("should have no duplicate entries within a single feature tag", () => { + // The registry intentionally allows the SAME entry to be registered under + // different feature tags (e.g. `.takt/facets/instructions/` is shared by + // both `commands` and `skills` for the takt target). The `resolveGitignoreEntries` + // writer dedupes the final output. What we want to forbid is the same + // (target, feature, entry) triple appearing twice. + const seen = new Set(); + const collisions: string[] = []; + for (const tag of GITIGNORE_ENTRY_REGISTRY) { + const targets = Array.isArray(tag.target) ? tag.target : [tag.target]; + for (const target of targets) { + const key = `${target}::${tag.feature}::${tag.entry}`; + if (seen.has(key)) { + collisions.push(key); + } + seen.add(key); + } + } + expect(collisions).toEqual([]); }); it("should cover all tool targets except intentionally excluded ones", () => { @@ -45,8 +60,12 @@ describe("GITIGNORE_ENTRY_REGISTRY", () => { }); describe("ALL_GITIGNORE_ENTRIES", () => { - it("should contain all entries from the registry", () => { - expect(ALL_GITIGNORE_ENTRIES.length).toBe(GITIGNORE_ENTRY_REGISTRY.length); + it("should contain every distinct entry from the registry", () => { + // The registry can register the same entry under multiple feature tags; + // `ALL_GITIGNORE_ENTRIES` is the deduplicated view, so its length matches + // the unique entry count rather than the raw registry length. + const distinctRegistryEntries = new Set(GITIGNORE_ENTRY_REGISTRY.map((tag) => tag.entry)); + expect(ALL_GITIGNORE_ENTRIES.length).toBe(distinctRegistryEntries.size); for (const tag of GITIGNORE_ENTRY_REGISTRY) { expect(ALL_GITIGNORE_ENTRIES).toContain(tag.entry); } diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 18a4fdba6..eff1325a3 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -223,6 +223,27 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ }, { target: "rovodev", feature: "skills", entry: "**/.agents/skills/" }, + // TAKT + // The `knowledge/` and `output-contracts/` facets are shared between the + // `rules` and `skills` features (both can opt into them via `takt.facet`). + // Register them under both feature tags so the entry survives when the + // user enables only one of the two. + { target: "takt", feature: "rules", entry: "**/.takt/facets/policies/" }, + { target: "takt", feature: "rules", entry: "**/.takt/facets/knowledge/" }, + { target: "takt", feature: "skills", entry: "**/.takt/facets/knowledge/" }, + { target: "takt", feature: "rules", entry: "**/.takt/facets/output-contracts/" }, + { target: "takt", feature: "skills", entry: "**/.takt/facets/output-contracts/" }, + { target: "takt", feature: "subagents", entry: "**/.takt/facets/personas/" }, + // Both commands and skills emit into `.takt/facets/instructions/`; register + // under both features so enabling either one is enough to gitignore the dir. + // The gitignore writer dedupes the entries on output. + { target: "takt", feature: "commands", entry: "**/.takt/facets/instructions/" }, + { target: "takt", feature: "skills", entry: "**/.takt/facets/instructions/" }, + { target: "takt", feature: "general", entry: "**/.takt/runs/" }, + { target: "takt", feature: "general", entry: "**/.takt/tasks/" }, + { target: "takt", feature: "general", entry: "**/.takt/.cache/" }, + { target: "takt", feature: "general", entry: "**/.takt/config.yaml" }, + // Windsurf { target: "windsurf", feature: "skills", entry: "**/.windsurf/skills/" }, { target: "windsurf", feature: "skills", entry: "**/.codeium/windsurf/skills/" }, @@ -232,9 +253,20 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "warp", feature: "rules", entry: "**/WARP.md" }, ] as const; -export const ALL_GITIGNORE_ENTRIES: ReadonlyArray = GITIGNORE_ENTRY_REGISTRY.map( - (tag) => tag.entry, -); +export const ALL_GITIGNORE_ENTRIES: ReadonlyArray = (() => { + // The registry may register the SAME entry under multiple feature tags + // (e.g. `.takt/facets/instructions/` is shared by both `commands` and + // `skills` for the takt target). The exported list dedupes while + // preserving the original insertion order. + const seen = new Set(); + const result: string[] = []; + for (const tag of GITIGNORE_ENTRY_REGISTRY) { + if (seen.has(tag.entry)) continue; + seen.add(tag.entry); + result.push(tag.entry); + } + return result; +})(); type FilterGitignoreEntriesParams = { readonly targets?: ReadonlyArray; diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 83b965f9f..92ca6dba0 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -26,6 +26,7 @@ describe("E2E: commands", () => { { target: "kiro", outputPath: join(".kiro", "prompts", "review-pr.md") }, { target: "antigravity", outputPath: join(".agent", "workflows", "review-pr.md") }, { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, + { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -158,6 +159,10 @@ describe("E2E: commands (global mode)", () => { { target: "cline", outputPath: join("Documents", "Cline", "Workflows", "review-pr.md") }, { target: "kilo", outputPath: join(".config", "kilo", "commands", "review-pr.md") }, { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, + { + target: "takt", + outputPath: join(".takt", "facets", "instructions", "review-pr.md"), + }, ])("should generate $target commands in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index fb2b9b36f..78f9b9982 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -71,6 +71,7 @@ This is a test rule for E2E testing. { target: "antigravity", outputPath: join(".agent", "rules", "overview.md") }, { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, { target: "windsurf", outputPath: join(".windsurf", "rules", "overview.md") }, + { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, ])("should generate $target rules (non-root)", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -262,6 +263,7 @@ describe("E2E: rules (global mode)", () => { { target: "factorydroid", outputPath: join(".factory", "AGENTS.md") }, { target: "kilo", outputPath: join(".config", "kilo", "AGENTS.md") }, { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, + { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, ])("should generate $target rules in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index 8254f5024..21b8e9359 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -79,6 +79,10 @@ describe("E2E: skills", () => { target: "agentsskills", outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), }, + { + target: "takt", + outputPath: join(".takt", "facets", "instructions", "test-skill.md"), + }, ])("should generate $target skills", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -265,6 +269,10 @@ describe("E2E: skills (global mode)", () => { target: "antigravity", outputPath: join(".gemini", "antigravity", "skills", "test-skill", "SKILL.md"), }, + { + target: "takt", + outputPath: join(".takt", "facets", "instructions", "test-skill.md"), + }, ])("should generate $target skills in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 14ad3ea17..5f8c58253 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -47,6 +47,10 @@ describe("E2E: subagents", () => { target: "junie", outputPath: join(".junie", "agents", "planner.md"), }, + { + target: "takt", + outputPath: join(".takt", "facets", "personas", "planner.md"), + }, ])("should generate $target subagents", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -229,6 +233,7 @@ describe("E2E: subagents (global mode)", () => { { target: "cursor", outputPath: join(".cursor", "agents", "planner.md") }, { target: "opencode", outputPath: join(".config", "opencode", "agent", "planner.md") }, { target: "rovodev", outputPath: join(".rovodev", "subagents", "planner.md") }, + { target: "takt", outputPath: join(".takt", "facets", "personas", "planner.md") }, ])("should generate $target subagents in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-takt.spec.ts b/src/e2e/e2e-takt.spec.ts new file mode 100644 index 000000000..31dcd7896 --- /dev/null +++ b/src/e2e/e2e-takt.spec.ts @@ -0,0 +1,122 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + RULESYNC_SKILLS_RELATIVE_DIR_PATH, +} from "../constants/rulesync-paths.js"; +import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { runGenerate, useTestDirectory } from "./e2e-helper.js"; + +describe("E2E: takt instructions-facet collisions", () => { + const { getTestDir } = useTestDirectory(); + + it("skips both colliding command and skill, keeps non-colliding files, logs a warning", async () => { + const testDir = getTestDir(); + + // Setup: a command and a skill that share the stem `review` (both write + // to .takt/facets/instructions/review.md). Plus a non-colliding command + // and a non-colliding skill so we can verify the rest of the run still + // produces output. + await writeFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review.md"), + `--- +description: "Review" +targets: ["*"] +--- +Command body for review. +`, + ); + await writeFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "ship.md"), + `--- +description: "Ship" +targets: ["*"] +--- +Command body for ship. +`, + ); + await writeFileContent( + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "review", "SKILL.md"), + `--- +name: review +description: "Review skill" +targets: ["*"] +--- +Skill body for review. +`, + ); + await writeFileContent( + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "runbook", "SKILL.md"), + `--- +name: runbook +description: "Runbook skill" +targets: ["*"] +--- +Skill body for runbook. +`, + ); + + const { stderr } = await runGenerate({ + target: "takt", + features: "commands,skills", + // Override NODE_ENV so the Logger's warn output reaches stderr (the + // default vitest NODE_ENV=test silences all Logger output). + env: { NODE_ENV: "e2e" }, + }); + + // Warning should mention the colliding sources and the conflicting target. + expect(stderr).toMatch(/TAKT collision/); + expect(stderr).toContain("review"); + + const collidingPath = join(testDir, ".takt", "facets", "instructions", "review.md"); + const nonCollidingCommandPath = join(testDir, ".takt", "facets", "instructions", "ship.md"); + const nonCollidingSkillPath = join(testDir, ".takt", "facets", "instructions", "runbook.md"); + + // Colliding output should NOT exist. + expect(await fileExists(collidingPath)).toBe(false); + + // Non-colliding command and skill should still be generated. + expect(await readFileContent(nonCollidingCommandPath)).toContain("Command body for ship."); + expect(await readFileContent(nonCollidingSkillPath)).toContain("Skill body for runbook."); + }); + + it("generates both files when stems do not collide (no warning)", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review.md"), + `--- +description: "Review" +targets: ["*"] +--- +Command body. +`, + ); + await writeFileContent( + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "runbook", "SKILL.md"), + `--- +name: runbook +description: "Runbook" +targets: ["*"] +--- +Skill body. +`, + ); + + const { stderr } = await runGenerate({ + target: "takt", + features: "commands,skills", + env: { NODE_ENV: "e2e" }, + }); + + expect(stderr).not.toMatch(/TAKT collision/); + expect( + await readFileContent(join(testDir, ".takt", "facets", "instructions", "review.md")), + ).toContain("Command body."); + expect( + await readFileContent(join(testDir, ".takt", "facets", "instructions", "runbook.md")), + ).toContain("Skill body."); + }); +}); diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 152531d75..24e588abd 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1136,6 +1136,7 @@ describe("CommandsProcessor", () => { "kiro", "opencode", "roo", + "takt", ]), ); }); @@ -1158,6 +1159,7 @@ describe("CommandsProcessor", () => { "kiro", "opencode", "roo", + "takt", ]), ); }); @@ -1177,6 +1179,7 @@ describe("CommandsProcessor", () => { "codexcli", "kilo", "opencode", + "takt", ]), ); }); diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 535167849..ec82cff75 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -24,6 +24,7 @@ import { KiroCommand } from "./kiro-command.js"; import { OpenCodeCommand } from "./opencode-command.js"; import { RooCommand } from "./roo-command.js"; import { RulesyncCommand } from "./rulesync-command.js"; +import { TaktCommand } from "./takt-command.js"; import { ToolCommand, ToolCommandForDeletionParams, @@ -78,6 +79,7 @@ const commandsProcessorToolTargetTuple = [ "kiro", "opencode", "roo", + "takt", ] as const; export type CommandsProcessorToolTarget = (typeof commandsProcessorToolTargetTuple)[number]; @@ -285,6 +287,19 @@ const toolCommandFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("returns the instructions facet directory", () => { + expect(TaktCommand.getSettablePaths().relativeDirPath).toBe( + join(".takt", "facets", "instructions"), + ); + }); + }); + + describe("resolveTaktCommandFacetDir", () => { + it("defaults to instructions", () => { + expect(resolveTaktCommandFacetDir(undefined, "x.md")).toBe("instructions"); + }); + it("accepts instruction", () => { + expect(resolveTaktCommandFacetDir("instruction", "x.md")).toBe("instructions"); + }); + it("rejects other values", () => { + expect(() => resolveTaktCommandFacetDir("knowledge", "x.md")).toThrow(/Invalid takt\.facet/); + }); + }); + + describe("fromRulesyncCommand", () => { + it("emits a plain Markdown body under instructions/", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "review.md", + frontmatter: { targets: ["*"], description: "Review PR" }, + body: "Run review steps.", + fileContent: "", + }); + + const cmd = TaktCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand }); + expect(cmd.getRelativeDirPath()).toBe(join(".takt", "facets", "instructions")); + expect(cmd.getRelativeFilePath()).toBe("review.md"); + expect(cmd.getFileContent()).toBe("Run review steps."); + }); + + it("renames the stem with takt.name", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "long-source.md", + frontmatter: { + targets: ["*"], + ...({ takt: { name: "short" } } as Record), + }, + body: "body", + fileContent: "", + }); + const cmd = TaktCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand }); + expect(cmd.getRelativeFilePath()).toBe("short.md"); + }); + + it("throws on an unsafe takt.name value", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "src.md", + frontmatter: { + targets: ["*"], + ...({ takt: { name: "../escape" } } as Record), + }, + body: "x", + fileContent: "", + }); + expect(() => TaktCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand })).toThrow( + /Invalid takt\.name/, + ); + }); + + it("throws on a disallowed takt.facet", () => { + const rulesyncCommand = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "p.md", + frontmatter: { + targets: ["*"], + ...({ takt: { facet: "policy" } } as Record), + }, + body: "x", + fileContent: "", + }); + expect(() => TaktCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand })).toThrow( + /Invalid takt\.facet/, + ); + }); + }); + + describe("fromFile", () => { + it("loads a plain Markdown instructions file", async () => { + const dir = join(testDir, ".takt", "facets", "instructions"); + await ensureDir(dir); + await writeFileContent(join(dir, "x.md"), "body\n"); + const cmd = await TaktCommand.fromFile({ baseDir: testDir, relativeFilePath: "x.md" }); + expect(cmd.getBody()).toBe("body"); + }); + }); + + describe("forDeletion", () => { + it("constructs a deletable empty instance", () => { + const cmd = TaktCommand.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "instructions"), + relativeFilePath: "x.md", + }); + expect(cmd.getFileContent()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it.each([ + [["*"], true], + [["takt"], true], + [["claudecode"], false], + ] as const)("targets=%j → %s", (targets, expected) => { + const r = new RulesyncCommand({ + baseDir: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "x.md", + frontmatter: { targets: [...targets] }, + body: "y", + fileContent: "", + }); + expect(TaktCommand.isTargetedByRulesyncCommand(r)).toBe(expected); + }); + }); +}); diff --git a/src/features/commands/takt-command.ts b/src/features/commands/takt-command.ts new file mode 100644 index 000000000..568026132 --- /dev/null +++ b/src/features/commands/takt-command.ts @@ -0,0 +1,171 @@ +import { join } from "node:path"; + +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { assertSafeTaktName, resolveTaktFacetDir } from "../takt-shared.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +/** + * Allowed `facet` values for TAKT command files. Commands are always + * placed in the `instructions/` directory; no override is permitted. + */ +export const TAKT_COMMAND_FACET_VALUES = ["instruction"] as const; +export type TaktCommandFacet = (typeof TAKT_COMMAND_FACET_VALUES)[number]; + +const DEFAULT_TAKT_COMMAND_DIR = "instructions"; + +const TAKT_COMMAND_FACET_TO_DIR: Record = { + instruction: DEFAULT_TAKT_COMMAND_DIR, +}; + +/** + * Validate the optional `takt.facet` value supplied on a command and return + * the corresponding directory name. + * + * @throws when an explicit `takt.facet` value is not allowed for commands + */ +export function resolveTaktCommandFacetDir(facetValue: unknown, sourceLabel: string): string { + return resolveTaktFacetDir({ + value: facetValue, + allowed: TAKT_COMMAND_FACET_VALUES, + defaultDir: DEFAULT_TAKT_COMMAND_DIR, + dirMap: TAKT_COMMAND_FACET_TO_DIR, + featureLabel: "command", + sourceLabel, + }); +} + +export type TaktCommandParams = { + body: string; +} & Omit; + +/** + * Command generator for TAKT. + * + * Commands are emitted as plain Markdown files under `.takt/facets/instructions/`. + * The original frontmatter is dropped; the body is written verbatim. The + * filename stem is preserved unless overridden via `takt.name`. + */ +export class TaktCommand extends ToolCommand { + private readonly body: string; + + constructor({ body, ...rest }: TaktCommandParams) { + super({ + ...rest, + fileContent: body, + }); + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolCommandSettablePaths { + return { + relativeDirPath: join(".takt", "facets", DEFAULT_TAKT_COMMAND_DIR), + }; + } + + getBody(): string { + return this.body; + } + + getFrontmatter(): Record { + return {}; + } + + toRulesyncCommand(): RulesyncCommand { + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["*"], + }; + return new RulesyncCommand({ + baseDir: ".", + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath, + fileContent: this.body, + validate: true, + }); + } + + static fromRulesyncCommand({ + baseDir = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): TaktCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const taktSection = rulesyncFrontmatter.takt; + const sourceLabel = rulesyncCommand.getRelativeFilePath(); + + // Validate facet (only `instruction` is allowed; default is also `instruction`) + resolveTaktCommandFacetDir(taktSection?.facet, sourceLabel); + + const overrideName = typeof taktSection?.name === "string" ? taktSection.name : undefined; + const sourceStem = rulesyncCommand.getRelativeFilePath().replace(/\.md$/u, ""); + const stem = overrideName ?? sourceStem; + assertSafeTaktName({ name: stem, featureLabel: "command", sourceLabel }); + const relativeFilePath = `${stem}.md`; + + const paths = this.getSettablePaths({ global }); + + return new TaktCommand({ + baseDir, + body: rulesyncCommand.getBody(), + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + validate, + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "takt", + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { body } = parseFrontmatter(fileContent, filePath); + + return new TaktCommand({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + body: body.trim(), + validate, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): TaktCommand { + return new TaktCommand({ + baseDir, + relativeDirPath, + relativeFilePath, + body: "", + validate: false, + }); + } +} diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index b04390bd1..b33890417 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -702,6 +702,7 @@ describe("RulesProcessor", () => { "opencode", "qwencode", "roo", + "takt", "warp", "windsurf", ]; @@ -872,6 +873,7 @@ Content that would fail parsing`; "kilo", "opencode", "rovodev", + "takt", ]); }); @@ -903,7 +905,8 @@ Content that would fail parsing`; expect(globalTargets).toContain("goose"); expect(globalTargets).toContain("opencode"); expect(globalTargets).toContain("rovodev"); - expect(globalTargets.length).toBe(11); + expect(globalTargets).toContain("takt"); + expect(globalTargets.length).toBe(12); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index b441f99cd..6d6465698 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -55,6 +55,7 @@ import { ReplitRule } from "./replit-rule.js"; import { RooRule } from "./roo-rule.js"; import { RovodevRule } from "./rovodev-rule.js"; import { RulesyncRule } from "./rulesync-rule.js"; +import { TaktRule } from "./takt-rule.js"; import { ToolRule, ToolRuleForDeletionParams, @@ -90,6 +91,7 @@ const rulesProcessorToolTargets: ToolTarget[] = [ "replit", "roo", "rovodev", + "takt", "warp", "windsurf", ]; @@ -507,6 +509,20 @@ const toolRuleFactories = new Map([ }, }, ], + [ + "takt", + { + class: TaktRule, + meta: { + extension: "md", + supportsGlobal: true, + ruleDiscoveryMode: "auto", + // No `additionalConventions` here: TAKT does not synthesize a root + // overview rule (TaktRule.fromRulesyncRule always emits non-root files), + // so the conventions block would never be rendered anywhere. + }, + }, + ], [ "warp", { diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index f6ec2890e..4e500da78 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -54,6 +54,15 @@ export const RulesyncRuleFrontmatterSchema = z.object({ globs: z.optional(z.array(z.string())), }), ), + takt: z.optional( + z.looseObject({ + // Override the default facet directory. Allowed values vary per feature + // (see takt-{feature}.ts for per-feature validation). + facet: z.optional(z.string()), + // Rename the emitted file stem (e.g. "coder.md" → "{name}.md"). + name: z.optional(z.string()), + }), + ), }); // Input type allows targets to be omitted (will use default value) diff --git a/src/features/rules/takt-rule.test.ts b/src/features/rules/takt-rule.test.ts new file mode 100644 index 000000000..be03ae92a --- /dev/null +++ b/src/features/rules/takt-rule.test.ts @@ -0,0 +1,210 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RULESYNC_RELATIVE_DIR_PATH, + RULESYNC_RULES_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { + DEFAULT_TAKT_RULE_DIR, + resolveTaktRuleFacetDir, + TAKT_RULE_FACET_VALUES, + TaktRule, +} from "./takt-rule.js"; + +describe("TaktRule", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("returns a non-root facet path in project mode", () => { + const paths = TaktRule.getSettablePaths(); + expect("nonRoot" in paths && paths.nonRoot?.relativeDirPath).toBe( + join(".takt", "facets", DEFAULT_TAKT_RULE_DIR), + ); + }); + + it("returns a root path in global mode", () => { + const paths = TaktRule.getSettablePaths({ global: true }); + expect("root" in paths && paths.root?.relativeDirPath).toBe( + join(".takt", "facets", DEFAULT_TAKT_RULE_DIR), + ); + }); + }); + + describe("resolveTaktRuleFacetDir", () => { + it("defaults to policies when facet is missing", () => { + expect(resolveTaktRuleFacetDir(undefined, "x.md")).toBe("policies"); + }); + + it.each(TAKT_RULE_FACET_VALUES.map((v) => [v]))("accepts allowed facet %s", (value) => { + expect(typeof resolveTaktRuleFacetDir(value, "x.md")).toBe("string"); + }); + + it("rejects disallowed facet values", () => { + expect(() => resolveTaktRuleFacetDir("persona", "x.md")).toThrow(/Invalid takt\.facet/); + }); + + it("rejects non-string facet values", () => { + expect(() => resolveTaktRuleFacetDir(42, "x.md")).toThrow(/expected a string/); + }); + }); + + describe("fromRulesyncRule", () => { + it("emits a plain Markdown body under the default facet", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "style.md", + frontmatter: { targets: ["*"] }, + body: "# Style policy", + }); + + const rule = TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule }); + + expect(rule.getRelativeDirPath()).toBe(join(".takt", "facets", "policies")); + expect(rule.getRelativeFilePath()).toBe("style.md"); + // No frontmatter — body only + expect(rule.getFileContent()).toBe("# Style policy"); + }); + + it("respects the takt.facet override", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "arch.md", + frontmatter: { + targets: ["*"], + // looseObject schema preserves unknown keys + ...({ takt: { facet: "knowledge" } } as Record), + }, + body: "# Architecture", + }); + + const rule = TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule }); + + expect(rule.getRelativeDirPath()).toBe(join(".takt", "facets", "knowledge")); + expect(rule.getRelativeFilePath()).toBe("arch.md"); + }); + + it("renames the emitted stem with takt.name", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "long-source-name.md", + frontmatter: { + targets: ["*"], + ...({ takt: { name: "short" } } as Record), + }, + body: "# Body", + }); + + const rule = TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule }); + expect(rule.getRelativeFilePath()).toBe("short.md"); + }); + + it("throws on an unsafe takt.name value", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "src.md", + frontmatter: { + targets: ["*"], + ...({ takt: { name: "../escape" } } as Record), + }, + body: "x", + }); + expect(() => TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule })).toThrow( + /Invalid takt\.name/, + ); + }); + + it("throws on a disallowed takt.facet value", () => { + const rulesyncRule = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "bad.md", + frontmatter: { + targets: ["*"], + ...({ takt: { facet: "persona" } } as Record), + }, + body: "x", + }); + + expect(() => TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule })).toThrow( + /Invalid takt\.facet/, + ); + }); + }); + + describe("fromFile", () => { + it("loads a plain Markdown facet file", async () => { + const facetDir = join(testDir, ".takt", "facets", "policies"); + await ensureDir(facetDir); + await writeFileContent(join(facetDir, "x.md"), "Hello body\n"); + + const rule = await TaktRule.fromFile({ baseDir: testDir, relativeFilePath: "x.md" }); + expect(rule.getFileContent()).toBe("Hello body"); + }); + }); + + describe("forDeletion", () => { + it("constructs a deletable instance without reading the file", () => { + const rule = TaktRule.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "policies"), + relativeFilePath: "x.md", + }); + expect(rule.getFileContent()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncRule", () => { + it("returns true when targets includes takt", () => { + const r = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "t.md", + frontmatter: { targets: ["takt"] }, + body: "x", + }); + expect(TaktRule.isTargetedByRulesyncRule(r)).toBe(true); + }); + + it("returns true on wildcard targets", () => { + const r = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "t.md", + frontmatter: { targets: ["*"] }, + body: "x", + }); + expect(TaktRule.isTargetedByRulesyncRule(r)).toBe(true); + }); + + it("returns false when targets excludes takt", () => { + const r = new RulesyncRule({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "t.md", + frontmatter: { targets: ["claudecode"] }, + body: "x", + }); + expect(TaktRule.isTargetedByRulesyncRule(r)).toBe(false); + }); + }); +}); diff --git a/src/features/rules/takt-rule.ts b/src/features/rules/takt-rule.ts new file mode 100644 index 000000000..f8ee5bf9b --- /dev/null +++ b/src/features/rules/takt-rule.ts @@ -0,0 +1,190 @@ +import { join } from "node:path"; + +import { ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { assertSafeTaktName, resolveTaktFacetDir } from "../takt-shared.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { + ToolRule, + ToolRuleForDeletionParams, + ToolRuleFromFileParams, + ToolRuleFromRulesyncRuleParams, + ToolRuleParams, + ToolRuleSettablePaths, + ToolRuleSettablePathsGlobal, + buildToolPath, +} from "./tool-rule.js"; + +/** + * Allowed `facet` values for TAKT rule files. + * + * - `policy`: hard rules and constraints (default) + * - `knowledge`: factual context (architecture, glossaries, references) + * - `output-contract`: output formatting / contract specifications + */ +export const TAKT_RULE_FACET_VALUES = ["policy", "knowledge", "output-contract"] as const; +export type TaktRuleFacet = (typeof TAKT_RULE_FACET_VALUES)[number]; + +const TAKT_RULE_FACET_TO_DIR: Record = { + policy: "policies", + knowledge: "knowledge", + "output-contract": "output-contracts", +}; + +const DEFAULT_TAKT_RULE_FACET: TaktRuleFacet = "policy"; + +/** Default facet directory used when `takt.facet` is not provided. */ +export const DEFAULT_TAKT_RULE_DIR = TAKT_RULE_FACET_TO_DIR[DEFAULT_TAKT_RULE_FACET]; + +export type TaktRuleParams = Omit & { + body: string; +}; + +/** + * Resolve the TAKT facet directory for a rules-feature file. + * + * @throws when an explicit `takt.facet` value is not allowed for the rules feature + */ +export function resolveTaktRuleFacetDir(facetValue: unknown, sourceLabel: string): string { + return resolveTaktFacetDir({ + value: facetValue, + allowed: TAKT_RULE_FACET_VALUES, + defaultDir: DEFAULT_TAKT_RULE_DIR, + dirMap: TAKT_RULE_FACET_TO_DIR, + featureLabel: "rule", + sourceLabel, + }); +} + +/** + * Rule generator for TAKT (https://github.com/dyoshikawa/takt). + * + * TAKT organizes prompts into faceted directories under `.takt/facets/`. + * Rulesync rules map to TAKT *policy* facets by default; the source frontmatter + * may override the facet via `takt.facet` (`policy`, `knowledge`, or + * `output-contract`) and may rename the emitted stem via `takt.name`. + * + * The emitted files are plain Markdown — frontmatter is always dropped, and + * the body is written verbatim. + */ +export class TaktRule extends ToolRule { + static getSettablePaths({ + global, + excludeToolDir, + }: { + global?: boolean; + excludeToolDir?: boolean; + } = {}): ToolRuleSettablePaths | ToolRuleSettablePathsGlobal { + if (global) { + return { + root: { + relativeDirPath: buildToolPath( + ".takt", + join("facets", DEFAULT_TAKT_RULE_DIR), + excludeToolDir, + ), + relativeFilePath: "overview.md", + }, + }; + } + return { + nonRoot: { + relativeDirPath: buildToolPath( + ".takt", + join("facets", DEFAULT_TAKT_RULE_DIR), + excludeToolDir, + ), + }, + }; + } + + constructor({ body, ...rest }: TaktRuleParams) { + super({ + ...rest, + fileContent: body, + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + relativeDirPath: overrideDirPath, + }: ToolRuleFromFileParams): Promise { + const dirPath = overrideDirPath ?? join(".takt", "facets", DEFAULT_TAKT_RULE_DIR); + const filePath = join(baseDir, dirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + // Strip frontmatter when present (TAKT files are plain Markdown by spec, but + // tolerate stray frontmatter on import for forward-compat). + const { body } = parseFrontmatter(fileContent, filePath); + + return new TaktRule({ + baseDir, + relativeDirPath: dirPath, + relativeFilePath, + body: body.trim(), + validate, + root: false, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolRuleForDeletionParams): TaktRule { + return new TaktRule({ + baseDir, + relativeDirPath, + relativeFilePath, + body: "", + validate: false, + root: false, + }); + } + + static fromRulesyncRule({ + baseDir = process.cwd(), + rulesyncRule, + validate = true, + }: ToolRuleFromRulesyncRuleParams): TaktRule { + const rulesyncFrontmatter = rulesyncRule.getFrontmatter(); + const taktSection = rulesyncFrontmatter.takt; + const sourceLabel = rulesyncRule.getRelativeFilePath(); + + const facetDir = resolveTaktRuleFacetDir(taktSection?.facet, sourceLabel); + + const overrideName = typeof taktSection?.name === "string" ? taktSection.name : undefined; + const sourceStem = rulesyncRule.getRelativeFilePath().replace(/\.md$/u, ""); + const stem = overrideName ?? sourceStem; + assertSafeTaktName({ name: stem, featureLabel: "rule", sourceLabel }); + const relativeFilePath = `${stem}.md`; + + const relativeDirPath = join(".takt", "facets", facetDir); + + return new TaktRule({ + baseDir, + relativeDirPath, + relativeFilePath, + body: rulesyncRule.getBody(), + validate, + root: false, + }); + } + + toRulesyncRule(): RulesyncRule { + return this.toRulesyncRuleDefault(); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { + return this.isTargetedByRulesyncRuleDefault({ + rulesyncRule, + toolTarget: "takt", + }); + } +} diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index c03e0def7..6c22fa80a 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -48,6 +48,15 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ ), cline: z.optional(z.looseObject({})), roo: z.optional(z.looseObject({})), + takt: z.optional( + z.looseObject({ + // Override the default facet directory. Allowed values: "instruction", + // "knowledge", "output-contract" (validation lives in takt-skill.ts). + facet: z.optional(z.string()), + // Rename the emitted file stem (e.g. "test-skill.md" → "{name}.md"). + name: z.optional(z.string()), + }), + ), }); // Export schema with targets optional for input but guaranteed in output @@ -80,6 +89,10 @@ export type RulesyncSkillFrontmatterInput = { }; roo?: Record; cline?: Record; + takt?: { + facet?: string; + name?: string; + }; }; // Type for output/validated data (targets is always present after validation) diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index 719c75891..da98a2478 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -774,6 +774,7 @@ Content that would fail parsing`; "replit", "roo", "rovodev", + "takt", "windsurf", ]), ); @@ -802,6 +803,7 @@ Content that would fail parsing`; "replit", "roo", "rovodev", + "takt", "windsurf", ]), ); @@ -828,6 +830,7 @@ Content that would fail parsing`; "replit", "roo", "rovodev", + "takt", "windsurf", ]), ); @@ -860,6 +863,7 @@ Content that would fail parsing`; "opencode", "roo", "rovodev", + "takt", "windsurf", ]); expect(targets).toEqual(skillsProcessorToolTargetsGlobal); @@ -881,6 +885,7 @@ Content that would fail parsing`; "opencode", "roo", "rovodev", + "takt", "windsurf", ]); expect(targets).toEqual(skillsProcessorToolTargetsGlobal); diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 9ccf237b6..a890e244d 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -30,6 +30,7 @@ import { RovodevSkill } from "./rovodev-skill.js"; import { RulesyncSkill } from "./rulesync-skill.js"; import { SimulatedSkill } from "./simulated-skill.js"; import { getLocalSkillDirNames } from "./skills-utils.js"; +import { TaktSkill } from "./takt-skill.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -86,6 +87,7 @@ const skillsProcessorToolTargetTuple = [ "replit", "roo", "rovodev", + "takt", "windsurf", ] as const; @@ -232,6 +234,13 @@ const toolSkillFactories = new Map( meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, }, ], + [ + "takt", + { + class: TaktSkill, + meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, + }, + ], [ "windsurf", { diff --git a/src/features/skills/takt-skill.test.ts b/src/features/skills/takt-skill.test.ts new file mode 100644 index 000000000..0d43b182c --- /dev/null +++ b/src/features/skills/takt-skill.test.ts @@ -0,0 +1,201 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { RulesyncSkill } from "./rulesync-skill.js"; +import { + DEFAULT_TAKT_SKILL_DIR, + resolveTaktSkillFacetDir, + TAKT_SKILL_FACET_VALUES, + TaktSkill, +} from "./takt-skill.js"; + +describe("TaktSkill", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("defaults to the instructions facet directory", () => { + expect(TaktSkill.getSettablePaths().relativeDirPath).toBe( + join(".takt", "facets", DEFAULT_TAKT_SKILL_DIR), + ); + }); + }); + + describe("resolveTaktSkillFacetDir", () => { + it("defaults to instructions when facet is missing", () => { + expect(resolveTaktSkillFacetDir(undefined, "x")).toBe("instructions"); + }); + + it.each(TAKT_SKILL_FACET_VALUES.map((v) => [v]))("accepts allowed facet %s", (value) => { + expect(typeof resolveTaktSkillFacetDir(value, "x")).toBe("string"); + }); + + it("rejects disallowed values", () => { + expect(() => resolveTaktSkillFacetDir("persona", "x")).toThrow(/Invalid takt\.facet/); + }); + }); + + describe("fromRulesyncSkill", () => { + it("emits a single flat .md file under the default facet", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + dirName: "runbook", + frontmatter: { + name: "runbook", + description: "runbook procedures", + targets: ["*"], + }, + body: "Runbook body", + }); + + const skill = TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill }); + expect(skill.getRelativeDirPath()).toBe(join(".takt", "facets", "instructions")); + expect(skill.getFileName()).toBe("runbook.md"); + expect(skill.getBody()).toBe("Runbook body"); + // getDirPath drops the dirName segment so the main file lands directly + // under the facet directory. + expect(skill.getDirPath()).toBe(join(testDir, ".takt", "facets", "instructions")); + }); + + it("respects the takt.facet override", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + dirName: "glossary", + frontmatter: { + name: "glossary", + description: "glossary", + targets: ["*"], + ...({ takt: { facet: "knowledge" } } as Record), + }, + body: "body", + }); + + const skill = TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill }); + expect(skill.getRelativeDirPath()).toBe(join(".takt", "facets", "knowledge")); + }); + + it("renames the stem with takt.name", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + dirName: "long-source", + frontmatter: { + name: "long-source", + description: "x", + targets: ["*"], + ...({ takt: { name: "short" } } as Record), + }, + body: "body", + }); + + const skill = TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill }); + expect(skill.getFileName()).toBe("short.md"); + }); + + it("throws on an unsafe takt.name value", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + dirName: "src", + frontmatter: { + name: "src", + description: "x", + targets: ["*"], + ...({ takt: { name: "../escape" } } as Record), + }, + body: "x", + }); + expect(() => TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill })).toThrow( + /Invalid takt\.name/, + ); + }); + + it("throws on a disallowed takt.facet", () => { + const rulesyncSkill = new RulesyncSkill({ + baseDir: testDir, + dirName: "p", + frontmatter: { + name: "p", + description: "x", + targets: ["*"], + ...({ takt: { facet: "persona" } } as Record), + }, + body: "x", + }); + expect(() => TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill })).toThrow( + /Invalid takt\.facet/, + ); + }); + }); + + describe("isTargetedByRulesyncSkill", () => { + it.each([ + [["*"], true], + [["takt"], true], + [["claudecode"], false], + ] as const)("targets=%j → %s", (targets, expected) => { + const r = new RulesyncSkill({ + baseDir: testDir, + dirName: "x", + frontmatter: { name: "x", description: "x", targets: [...targets] }, + body: "y", + }); + expect(TaktSkill.isTargetedByRulesyncSkill(r)).toBe(expected); + }); + }); + + describe("getDirPath path-traversal guard", () => { + it("throws when relativeDirPath escapes baseDir", () => { + const skill = new TaktSkill({ + baseDir: testDir, + relativeDirPath: join("..", "escape"), + dirName: "x", + fileName: "x.md", + body: "y", + validate: false, + }); + expect(() => skill.getDirPath()).toThrow(/Path traversal detected/); + }); + }); + + describe("fromDir / toRulesyncSkill (unsupported)", () => { + it("fromDir throws because reverse import is unsupported", async () => { + await expect(TaktSkill.fromDir({ baseDir: testDir, dirName: "x" })).rejects.toThrow( + /not supported/, + ); + }); + + it("toRulesyncSkill throws because reverse import is unsupported", () => { + const skill = new TaktSkill({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "instructions"), + dirName: "x", + fileName: "x.md", + body: "y", + }); + expect(() => skill.toRulesyncSkill()).toThrow(/not supported/); + }); + }); + + describe("forDeletion", () => { + it("constructs an empty instance for deletion", () => { + const skill = TaktSkill.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "instructions"), + dirName: "x", + }); + expect(skill.getBody()).toBe(""); + expect(skill.getFileName()).toBe("x.md"); + }); + }); +}); diff --git a/src/features/skills/takt-skill.ts b/src/features/skills/takt-skill.ts new file mode 100644 index 000000000..f2125740d --- /dev/null +++ b/src/features/skills/takt-skill.ts @@ -0,0 +1,242 @@ +import path, { join, relative, resolve } from "node:path"; + +import { ValidationResult } from "../../types/ai-dir.js"; +import { toPosixPath } from "../../utils/file.js"; +import { assertSafeTaktName, resolveTaktFacetDir } from "../takt-shared.js"; +import { RulesyncSkill, SkillFile } from "./rulesync-skill.js"; +import { + ToolSkill, + ToolSkillForDeletionParams, + ToolSkillFromDirParams, + ToolSkillFromRulesyncSkillParams, + ToolSkillSettablePaths, +} from "./tool-skill.js"; + +/** + * Allowed `facet` values for TAKT skill files. + * + * - `instruction`: behavioral instructions (default) + * - `knowledge`: factual context + * - `output-contract`: output formatting / contract specifications + */ +export const TAKT_SKILL_FACET_VALUES = ["instruction", "knowledge", "output-contract"] as const; +export type TaktSkillFacet = (typeof TAKT_SKILL_FACET_VALUES)[number]; + +const TAKT_SKILL_FACET_TO_DIR: Record = { + instruction: "instructions", + knowledge: "knowledge", + "output-contract": "output-contracts", +}; + +const DEFAULT_TAKT_SKILL_FACET: TaktSkillFacet = "instruction"; + +/** Default facet directory used when `takt.facet` is not provided. */ +export const DEFAULT_TAKT_SKILL_DIR = TAKT_SKILL_FACET_TO_DIR[DEFAULT_TAKT_SKILL_FACET]; + +/** + * Resolve the TAKT facet directory for a skills-feature file. + * + * @throws when an explicit `takt.facet` value is not allowed for skills + */ +export function resolveTaktSkillFacetDir(facetValue: unknown, sourceLabel: string): string { + return resolveTaktFacetDir({ + value: facetValue, + allowed: TAKT_SKILL_FACET_VALUES, + defaultDir: DEFAULT_TAKT_SKILL_DIR, + dirMap: TAKT_SKILL_FACET_TO_DIR, + featureLabel: "skill", + sourceLabel, + }); +} + +export type TaktSkillParams = { + baseDir?: string; + relativeDirPath: string; + dirName: string; + /** File name (with `.md` extension) to emit under `relativeDirPath`. */ + fileName: string; + body: string; + otherFiles?: SkillFile[]; + validate?: boolean; + global?: boolean; +}; + +/** + * Skill generator for TAKT. + * + * Unlike most other tools, TAKT skills are emitted as flat Markdown files + * (one `.md` per skill) under `.takt/facets/instructions/` (default), + * `.takt/facets/knowledge/`, or `.takt/facets/output-contracts/`. + * + * To remain compatible with the directory-based `AiDir` abstraction this + * class still tracks a `dirName` (used for routing and deletion), but + * `getDirPath()` is overridden to drop the trailing directory segment so + * that the emitted main file lands directly under the facet directory: + * + * `.takt/facets/{facet}/{stem}.md` + * + * The original frontmatter is dropped — only the body is written verbatim. + */ +export class TaktSkill extends ToolSkill { + private readonly fileName: string; + + constructor({ + baseDir = process.cwd(), + relativeDirPath, + dirName, + fileName, + body, + otherFiles = [], + validate = true, + global = false, + }: TaktSkillParams) { + super({ + baseDir, + relativeDirPath, + dirName, + mainFile: { + name: fileName, + body, + // Frontmatter is intentionally undefined — TAKT files are plain Markdown. + frontmatter: undefined, + }, + otherFiles, + global, + }); + this.fileName = fileName; + + if (validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolSkillSettablePaths { + return { + relativeDirPath: join(".takt", "facets", DEFAULT_TAKT_SKILL_DIR), + }; + } + + /** + * Override: TAKT skills emit a single flat file under `relativeDirPath`, + * not a nested directory keyed by `dirName`. Drop `dirName` from the path. + * + * Preserves the same path-traversal guard as `AiDir.getDirPath` so a + * malicious `relativeDirPath` cannot escape `baseDir`. + */ + override getDirPath(): string { + const fullPath = join(this.baseDir, this.relativeDirPath); + + const resolvedFull = resolve(fullPath); + const resolvedBase = resolve(this.baseDir); + const rel = relative(resolvedBase, resolvedFull); + + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error( + `Path traversal detected: Final path escapes baseDir. ` + + `baseDir="${this.baseDir}", relativeDirPath="${this.relativeDirPath}"`, + ); + } + + return fullPath; + } + + override getRelativePathFromCwd(): string { + return toPosixPath(this.relativeDirPath); + } + + getBody(): string { + return this.mainFile?.body ?? ""; + } + + getFileName(): string { + return this.fileName; + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + toRulesyncSkill(): RulesyncSkill { + // Reverse-mapping from the flat TAKT layout into a directory-based + // `RulesyncSkill` cannot recover the original `description`/`facet` + // metadata (TAKT files are plain Markdown). Fail loudly rather than + // silently emit a synthetic stub. + throw new Error( + "Importing existing TAKT facet files into rulesync is not supported: " + + "TAKT files are plain Markdown and the original skill metadata cannot be recovered.", + ); + } + + static fromRulesyncSkill({ + baseDir = process.cwd(), + rulesyncSkill, + validate = true, + global = false, + }: ToolSkillFromRulesyncSkillParams): TaktSkill { + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const taktSection = rulesyncFrontmatter.takt; + const sourceLabel = rulesyncSkill.getDirName(); + + const facetDir = resolveTaktSkillFacetDir(taktSection?.facet, sourceLabel); + + const overrideName = typeof taktSection?.name === "string" ? taktSection.name : undefined; + const stem = overrideName ?? rulesyncSkill.getDirName(); + assertSafeTaktName({ name: stem, featureLabel: "skill", sourceLabel }); + const fileName = `${stem}.md`; + + const relativeDirPath = join(".takt", "facets", facetDir); + + return new TaktSkill({ + baseDir, + relativeDirPath, + dirName: stem, + fileName, + body: rulesyncSkill.getBody(), + otherFiles: rulesyncSkill.getOtherFiles(), + validate, + global, + }); + } + + static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { + const targets = rulesyncSkill.getFrontmatter().targets; + return targets.includes("*") || targets.includes("takt"); + } + + /** + * Importing existing TAKT facet files into rulesync is not supported. + * + * TAKT emits flat plain-Markdown files (no `SKILL.md` directory layout, no + * frontmatter). The reverse import would have to invent a skill name and + * description out of the file stem alone, which silently produces a stub + * that round-trips badly. Throwing makes the limitation explicit at the + * call site rather than letting bad data sneak through. + */ + static async fromDir(_params: ToolSkillFromDirParams): Promise { + throw new Error( + "Importing existing TAKT facet files into rulesync is not supported: " + + "TAKT files are plain Markdown and the original skill metadata cannot be recovered.", + ); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + dirName, + global = false, + }: ToolSkillForDeletionParams): TaktSkill { + return new TaktSkill({ + baseDir, + relativeDirPath, + dirName, + fileName: `${dirName}.md`, + body: "", + otherFiles: [], + validate: false, + global, + }); + } +} diff --git a/src/features/subagents/rulesync-subagent.ts b/src/features/subagents/rulesync-subagent.ts index d0ed08141..2394c9ae9 100644 --- a/src/features/subagents/rulesync-subagent.ts +++ b/src/features/subagents/rulesync-subagent.ts @@ -20,6 +20,12 @@ export const RulesyncSubagentFrontmatterSchema = z.looseObject({ targets: z._default(RulesyncTargetsSchema, ["*"]), name: z.string(), description: z.optional(z.string()), + takt: z.optional( + z.looseObject({ + facet: z.optional(z.string()), + name: z.optional(z.string()), + }), + ), }); // Input type allows targets to be omitted (will use default value) diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 7e4012eb2..e0e042d40 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -930,6 +930,7 @@ Second global content`; "kilo", "opencode", "rovodev", + "takt", ]); }); @@ -974,6 +975,7 @@ Second global content`; "opencode", "roo", "rovodev", + "takt", ]), ); expect(Array.isArray(subagentsProcessorToolTargets)).toBe(true); diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 9675b99d2..805578bff 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -25,6 +25,7 @@ import { RooSubagent } from "./roo-subagent.js"; import { RovodevSubagent } from "./rovodev-subagent.js"; import { RulesyncSubagent } from "./rulesync-subagent.js"; import { SimulatedSubagent } from "./simulated-subagent.js"; +import { TaktSubagent } from "./takt-subagent.js"; import { ToolSubagent, ToolSubagentForDeletionParams, @@ -75,6 +76,7 @@ const subagentsProcessorToolTargetTuple = [ "opencode", "roo", "rovodev", + "takt", ] as const; export type SubagentsProcessorToolTarget = (typeof subagentsProcessorToolTargetTuple)[number]; @@ -192,6 +194,13 @@ const toolSubagentFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("returns the personas facet directory", () => { + expect(TaktSubagent.getSettablePaths().relativeDirPath).toBe( + join(".takt", "facets", "personas"), + ); + }); + }); + + describe("resolveTaktSubagentFacetDir", () => { + it("defaults to personas", () => { + expect(resolveTaktSubagentFacetDir(undefined, "x.md")).toBe("personas"); + }); + it("accepts persona", () => { + expect(resolveTaktSubagentFacetDir("persona", "x.md")).toBe("personas"); + }); + it("rejects other values", () => { + expect(() => resolveTaktSubagentFacetDir("policy", "x.md")).toThrow(/Invalid takt\.facet/); + }); + }); + + describe("fromRulesyncSubagent", () => { + it("emits a plain Markdown body under personas/", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "planner.md", + frontmatter: { + targets: ["*"], + name: "planner", + description: "plans", + }, + body: "You are the planner.", + }); + + const sub = TaktSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "personas"), + rulesyncSubagent, + }); + expect(sub.getRelativeDirPath()).toBe(join(".takt", "facets", "personas")); + expect(sub.getRelativeFilePath()).toBe("planner.md"); + expect(sub.getFileContent()).toBe("You are the planner."); + }); + + it("renames the stem with takt.name", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "long.md", + frontmatter: { + targets: ["*"], + name: "long", + ...({ takt: { name: "short" } } as Record), + }, + body: "body", + }); + + const sub = TaktSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "personas"), + rulesyncSubagent, + }); + expect(sub.getRelativeFilePath()).toBe("short.md"); + }); + + it("throws on an unsafe takt.name value", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "src.md", + frontmatter: { + targets: ["*"], + name: "src", + ...({ takt: { name: "../escape" } } as Record), + }, + body: "x", + }); + expect(() => + TaktSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "personas"), + rulesyncSubagent, + }), + ).toThrow(/Invalid takt\.name/); + }); + + it("throws on a disallowed takt.facet", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "p.md", + frontmatter: { + targets: ["*"], + name: "p", + ...({ takt: { facet: "policy" } } as Record), + }, + body: "x", + }); + expect(() => + TaktSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "personas"), + rulesyncSubagent, + }), + ).toThrow(/Invalid takt\.facet/); + }); + }); + + describe("fromFile", () => { + it("loads a plain Markdown personas file", async () => { + const dir = join(testDir, ".takt", "facets", "personas"); + await ensureDir(dir); + await writeFileContent(join(dir, "p.md"), "body\n"); + + const sub = await TaktSubagent.fromFile({ baseDir: testDir, relativeFilePath: "p.md" }); + expect(sub.getBody()).toBe("body"); + }); + }); + + describe("forDeletion", () => { + it("constructs a deletable empty instance", () => { + const sub = TaktSubagent.forDeletion({ + baseDir: testDir, + relativeDirPath: join(".takt", "facets", "personas"), + relativeFilePath: "p.md", + }); + expect(sub.getFileContent()).toBe(""); + }); + }); + + describe("isTargetedByRulesyncSubagent", () => { + it.each([ + [["*"], true], + [["takt"], true], + [["claudecode"], false], + ] as const)("targets=%j → %s", (targets, expected) => { + const r = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "x.md", + frontmatter: { targets: [...targets], name: "x" }, + body: "y", + }); + expect(TaktSubagent.isTargetedByRulesyncSubagent(r)).toBe(expected); + }); + }); +}); diff --git a/src/features/subagents/takt-subagent.ts b/src/features/subagents/takt-subagent.ts new file mode 100644 index 000000000..f1e8e5cf9 --- /dev/null +++ b/src/features/subagents/takt-subagent.ts @@ -0,0 +1,172 @@ +import { join } from "node:path"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { assertSafeTaktName, resolveTaktFacetDir } from "../takt-shared.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +/** + * Allowed `facet` values for TAKT subagent files. Subagents are always + * placed in the `personas/` directory; no override is permitted. + */ +export const TAKT_SUBAGENT_FACET_VALUES = ["persona"] as const; +export type TaktSubagentFacet = (typeof TAKT_SUBAGENT_FACET_VALUES)[number]; + +const DEFAULT_TAKT_SUBAGENT_DIR = "personas"; + +const TAKT_SUBAGENT_FACET_TO_DIR: Record = { + persona: DEFAULT_TAKT_SUBAGENT_DIR, +}; + +/** + * Validate the optional `takt.facet` value supplied on a subagent and return + * the corresponding directory name. + * + * @throws when an explicit `takt.facet` value is not allowed for subagents + */ +export function resolveTaktSubagentFacetDir(facetValue: unknown, sourceLabel: string): string { + return resolveTaktFacetDir({ + value: facetValue, + allowed: TAKT_SUBAGENT_FACET_VALUES, + defaultDir: DEFAULT_TAKT_SUBAGENT_DIR, + dirMap: TAKT_SUBAGENT_FACET_TO_DIR, + featureLabel: "subagent", + sourceLabel, + }); +} + +export type TaktSubagentParams = { + body: string; +} & AiFileParams; + +/** + * Subagent generator for TAKT. + * + * Subagents are emitted as plain Markdown files under `.takt/facets/personas/`. + * The original frontmatter is dropped; the body is written verbatim. The + * filename stem is preserved unless overridden via `takt.name`. + */ +export class TaktSubagent extends ToolSubagent { + private readonly body: string; + + constructor({ body, ...rest }: TaktSubagentParams) { + super({ + ...rest, + }); + this.body = body; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: join(".takt", "facets", DEFAULT_TAKT_SUBAGENT_DIR), + }; + } + + getBody(): string { + return this.body; + } + + toRulesyncSubagent(): RulesyncSubagent { + const stem = this.getRelativeFilePath().replace(/\.md$/u, ""); + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name: stem, + }; + return new RulesyncSubagent({ + baseDir: ".", + frontmatter: rulesyncFrontmatter, + body: this.body, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: this.getRelativeFilePath(), + validate: true, + }); + } + + static fromRulesyncSubagent({ + baseDir = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): TaktSubagent { + const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); + const taktSection = rulesyncFrontmatter.takt; + const sourceLabel = rulesyncSubagent.getRelativeFilePath(); + + // Validate facet override (only `persona` is allowed; default is also `persona`) + resolveTaktSubagentFacetDir(taktSection?.facet, sourceLabel); + + const overrideName = typeof taktSection?.name === "string" ? taktSection.name : undefined; + const sourceStem = rulesyncSubagent.getRelativeFilePath().replace(/\.md$/u, ""); + const stem = overrideName ?? sourceStem; + assertSafeTaktName({ name: stem, featureLabel: "subagent", sourceLabel }); + const relativeFilePath = `${stem}.md`; + + const paths = this.getSettablePaths({ global }); + const body = rulesyncSubagent.getBody(); + + return new TaktSubagent({ + baseDir, + body, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + fileContent: body, + validate, + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "takt", + }); + } + + static async fromFile({ + baseDir = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + const { body } = parseFrontmatter(fileContent, filePath); + + return new TaktSubagent({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + body: body.trim(), + fileContent, + validate, + }); + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): TaktSubagent { + return new TaktSubagent({ + baseDir, + relativeDirPath, + relativeFilePath, + body: "", + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/takt-shared.test.ts b/src/features/takt-shared.test.ts new file mode 100644 index 000000000..a963bb652 --- /dev/null +++ b/src/features/takt-shared.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { assertSafeTaktName, resolveTaktFacetDir } from "./takt-shared.js"; + +describe("assertSafeTaktName", () => { + it.each([["plain"], ["with-dash"], ["with_underscore"], ["with.dot"], ["mixed-1.2_3"]])( + "accepts safe name %s", + (name) => { + expect(() => + assertSafeTaktName({ name, featureLabel: "rule", sourceLabel: "x.md" }), + ).not.toThrow(); + }, + ); + + it.each([ + ["with/slash"], + ["with\\backslash"], + [".."], + ["."], + ["a/b"], + ["../escape"], + ["a..b/c"], + ["a b"], + ["with$"], + ["with-emoji-\u{1F600}"], + [""], + ])("rejects unsafe name %s", (name) => { + expect(() => assertSafeTaktName({ name, featureLabel: "rule", sourceLabel: "x.md" })).toThrow( + /Invalid takt\.name/, + ); + }); + + it("includes feature label and source label in the error message", () => { + expect(() => + assertSafeTaktName({ name: "../bad", featureLabel: "skill", sourceLabel: "src.md" }), + ).toThrow(/skill "src\.md"/); + }); +}); + +describe("resolveTaktFacetDir", () => { + const allowed = ["a", "b"] as const; + const dirMap = { a: "as", b: "bs" } as const; + + it("returns default when value is undefined", () => { + expect( + resolveTaktFacetDir({ + value: undefined, + allowed, + defaultDir: "as", + dirMap, + featureLabel: "rule", + sourceLabel: "x.md", + }), + ).toBe("as"); + }); + + it("returns mapped directory for allowed value", () => { + expect( + resolveTaktFacetDir({ + value: "b", + allowed, + defaultDir: "as", + dirMap, + featureLabel: "rule", + sourceLabel: "x.md", + }), + ).toBe("bs"); + }); + + it("throws on non-string value", () => { + expect(() => + resolveTaktFacetDir({ + value: 5, + allowed, + defaultDir: "as", + dirMap, + featureLabel: "rule", + sourceLabel: "x.md", + }), + ).toThrow(/expected a string/); + }); + + it("throws on disallowed value", () => { + expect(() => + resolveTaktFacetDir({ + value: "z", + allowed, + defaultDir: "as", + dirMap, + featureLabel: "rule", + sourceLabel: "x.md", + }), + ).toThrow(/Invalid takt\.facet "z"/); + }); +}); diff --git a/src/features/takt-shared.ts b/src/features/takt-shared.ts new file mode 100644 index 000000000..26764bb63 --- /dev/null +++ b/src/features/takt-shared.ts @@ -0,0 +1,91 @@ +import { z } from "zod/mini"; + +/** + * Shared utilities for all TAKT-* tool file classes. + * + * TAKT emits flat plain-Markdown facet files under `.takt/facets//.md`. + * The filename stem may be overridden via `takt.name` in the source frontmatter. + * Because the stem becomes a real filename component, it must be validated to + * prevent path traversal or accidental directory escapes. + */ + +/** + * Allowed characters in a TAKT filename stem. + * + * Conservative on purpose: ASCII letters/digits, underscore, hyphen, and dot. + * Forbids `/`, `\`, leading/embedded `..`, and any other separator-like char. + */ +const TAKT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/u; + +/** + * Validate that a TAKT filename stem (`takt.name` or the source stem) is + * safe to use as a filename component. Throws a clear error otherwise. + * + * @param name The proposed filename stem (without `.md`). + * @param featureLabel The rulesync feature name (e.g. `"rule"`, `"skill"`). + * @param sourceLabel A reference to the source file (used in the error message). + */ +export function assertSafeTaktName({ + name, + featureLabel, + sourceLabel, +}: { + name: string; + featureLabel: string; + sourceLabel: string; +}): void { + if ( + !TAKT_NAME_PATTERN.test(name) || + name === "." || + name === ".." || + name.split(/[.]/u).some((segment) => segment === "..") + ) { + throw new Error( + `Invalid takt.name "${name}" for ${featureLabel} "${sourceLabel}": ` + + `filename stems may not contain path separators or ".." segments.`, + ); + } +} + +/** + * Resolve a TAKT facet directory from an optional `takt.facet` value. + * + * Generic helper used by all four TAKT-* tool file classes (rule, subagent, + * command, skill). Each feature defines its own allowed values and default; + * this helper validates the supplied value and returns the corresponding + * directory name. + * + * @throws when `value` is non-string or not in `allowed`. + */ +export function resolveTaktFacetDir({ + value, + allowed, + defaultDir, + dirMap, + featureLabel, + sourceLabel, +}: { + value: unknown; + allowed: ReadonlyArray; + defaultDir: string; + dirMap: Readonly>; + featureLabel: string; + sourceLabel: string; +}): string { + if (value === undefined || value === null) { + return defaultDir; + } + if (typeof value !== "string") { + throw new Error( + `Invalid takt.facet for ${featureLabel} "${sourceLabel}": expected a string, got ${typeof value}.`, + ); + } + const parsed = z.enum(allowed).safeParse(value); + if (!parsed.success) { + throw new Error( + `Invalid takt.facet "${value}" for ${featureLabel} "${sourceLabel}". ` + + `Allowed values for ${featureLabel}s: ${allowed.join(", ")}.`, + ); + } + return dirMap[parsed.data]; +} diff --git a/src/lib/generate.ts b/src/lib/generate.ts index b7512a3f7..3e852d87d 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -5,6 +5,8 @@ import { intersection } from "es-toolkit"; import { Config } from "../config/config.js"; import { RULESYNC_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import { CommandsProcessor } from "../features/commands/commands-processor.js"; +import { RulesyncCommand } from "../features/commands/rulesync-command.js"; +import { TaktCommand } from "../features/commands/takt-command.js"; import { HooksProcessor } from "../features/hooks/hooks-processor.js"; import { IgnoreProcessor } from "../features/ignore/ignore-processor.js"; import { McpProcessor } from "../features/mcp/mcp-processor.js"; @@ -12,6 +14,7 @@ import { PermissionsProcessor } from "../features/permissions/permissions-proces import { RulesProcessor } from "../features/rules/rules-processor.js"; import { RulesyncSkill } from "../features/skills/rulesync-skill.js"; import { SkillsProcessor } from "../features/skills/skills-processor.js"; +import { TaktSkill } from "../features/skills/takt-skill.js"; import { SubagentsProcessor } from "../features/subagents/subagents-processor.js"; import { AiDir } from "../types/ai-dir.js"; import { AiFile } from "../types/ai-file.js"; @@ -182,9 +185,20 @@ export async function generate(params: { const ignoreResult = await generateIgnoreCore({ config, logger }); const mcpResult = await generateMcpCore({ config, logger }); - const commandsResult = await generateCommandsCore({ config, logger }); + + // For the TAKT target, commands and skills can both write to + // `.takt/facets/instructions/.md`. Pre-compute the set of colliding + // stems per baseDir so both `generateCommandsCore` and `generateSkillsCore` + // can SKIP the colliding files and continue with everything else. + const taktInstructionsCollisions = await computeTaktInstructionsCollisions({ config, logger }); + + const commandsResult = await generateCommandsCore({ + config, + logger, + taktInstructionsCollisions, + }); const subagentsResult = await generateSubagentsCore({ config, logger }); - const skillsResult = await generateSkillsCore({ config, logger }); + const skillsResult = await generateSkillsCore({ config, logger, taktInstructionsCollisions }); const hooksResult = await generateHooksCore({ config, logger }); // NOTE: Permissions MUST run after ignore. Both features write to `.claude/settings.json` // (ignore writes Read deny entries, permissions merges all permission arrays). @@ -377,8 +391,9 @@ async function generateMcpCore(params: { async function generateCommandsCore(params: { config: Config; logger: Logger; + taktInstructionsCollisions?: Map>; }): Promise { - const { config, logger } = params; + const { config, logger, taktInstructionsCollisions } = params; let totalCount = 0; const allPaths: string[] = []; @@ -413,7 +428,19 @@ async function generateCommandsCore(params: { }); const rulesyncFiles = await processor.loadRulesyncFiles(); - const result = await processFeatureWithRulesyncFiles({ config, processor, rulesyncFiles }); + + let result: FeatureGenerateResult; + if (toolTarget === "takt" && taktInstructionsCollisions) { + const collisionStems = taktInstructionsCollisions.get(baseDir) ?? new Set(); + result = await processTaktCommandsWithCollisionFilter({ + config, + processor, + rulesyncFiles, + collisionStems, + }); + } else { + result = await processFeatureWithRulesyncFiles({ config, processor, rulesyncFiles }); + } totalCount += result.count; allPaths.push(...result.paths); @@ -477,8 +504,9 @@ async function generateSubagentsCore(params: { async function generateSkillsCore(params: { config: Config; logger: Logger; + taktInstructionsCollisions?: Map>; }): Promise { - const { config, logger } = params; + const { config, logger, taktInstructionsCollisions } = params; let totalCount = 0; const allPaths: string[] = []; @@ -521,7 +549,15 @@ async function generateSkillsCore(params: { } } - const toolDirs = await processor.convertRulesyncDirsToToolDirs(rulesyncDirs); + const allToolDirs = await processor.convertRulesyncDirsToToolDirs(rulesyncDirs); + + const toolDirs = + toolTarget === "takt" && taktInstructionsCollisions + ? filterTaktSkillsCollisions({ + toolDirs: allToolDirs, + collisionStems: taktInstructionsCollisions.get(baseDir) ?? new Set(), + }) + : allToolDirs; const result = await processDirFeatureGeneration({ config, @@ -636,3 +672,216 @@ async function generatePermissionsCore(params: { return { count: totalCount, paths: allPaths, hasDiff }; } + +/** + * Pre-compute the set of TAKT instruction-facet filename stems that would + * collide between commands and skills (both can write to + * `.takt/facets/instructions/.md`). + * + * Returns a Map keyed by `baseDir`. The set inside contains stems (no `.md`) + * that should be SKIPPED by both `generateCommandsCore` and + * `generateSkillsCore`. Warnings for each collision are logged from this + * function so the warning fires exactly once per collision regardless of + * which feature runs first. + * + * Called once from `generate()` before the per-feature passes. If the takt + * target is not selected, returns an empty map and the per-feature filtering + * paths become no-ops. + */ +async function computeTaktInstructionsCollisions(params: { + config: Config; + logger: Logger; +}): Promise>> { + const { config, logger } = params; + const result = new Map>(); + + if (!config.getTargets().includes("takt")) { + return result; + } + + const taktFeatures = config.getFeatures("takt"); + const taktHasCommands = taktFeatures.includes("commands"); + const taktHasSkills = taktFeatures.includes("skills"); + if (!taktHasCommands || !taktHasSkills) { + return result; + } + + for (const baseDir of config.getBaseDirs()) { + try { + const collisionStems = await detectTaktInstructionsCollisionsForBaseDir({ + baseDir, + global: config.getGlobal(), + logger, + }); + result.set(baseDir, collisionStems); + } catch (error) { + logger.warn( + `Failed to detect TAKT instruction-facet collisions for ${baseDir}: ${formatError(error)}`, + ); + continue; + } + } + + return result; +} + +async function detectTaktInstructionsCollisionsForBaseDir(params: { + baseDir: string; + global: boolean; + logger: Logger; +}): Promise> { + const { baseDir, global, logger } = params; + + // Load both candidate sources and pre-compute their planned filenames + // by running them through TAKT's own conversion functions. This guarantees + // the collision check operates on the EXACT filenames TAKT would write, + // including any `takt.name` overrides. + const commandsProcessor = new CommandsProcessor({ + baseDir, + toolTarget: "takt", + global, + dryRun: true, + logger, + }); + const skillsProcessor = new SkillsProcessor({ + baseDir, + toolTarget: "takt", + global, + dryRun: true, + logger, + }); + + const rulesyncCommandFiles = await commandsProcessor.loadRulesyncFiles(); + const rulesyncSkillDirs = await skillsProcessor.loadRulesyncDirs(); + + const commandStems = new Map(); // stem → source label + for (const file of rulesyncCommandFiles) { + if (!(file instanceof RulesyncCommand)) continue; + if (!TaktCommand.isTargetedByRulesyncCommand(file)) continue; + try { + const tool = TaktCommand.fromRulesyncCommand({ + baseDir, + rulesyncCommand: file, + validate: false, + }); + const stem = tool.getRelativeFilePath().replace(/\.md$/u, ""); + commandStems.set(stem, file.getRelativeFilePath()); + } catch (error) { + logger.debug( + `TAKT collision pre-pass: skipping command "${file.getRelativeFilePath()}" due to conversion error: ${formatError(error)}`, + ); + continue; + } + } + + const skillStems = new Map(); + for (const dir of rulesyncSkillDirs) { + if (!(dir instanceof RulesyncSkill)) continue; + if (!TaktSkill.isTargetedByRulesyncSkill(dir)) continue; + try { + const tool = TaktSkill.fromRulesyncSkill({ + baseDir, + rulesyncSkill: dir, + validate: false, + }); + // For skills, the "stem" written to disk is the file name without `.md`. + const stem = tool.getFileName().replace(/\.md$/u, ""); + // Skills only collide with commands when their facet directory is the + // shared "instructions" directory. Other facets (knowledge / output-contracts) + // are not shared with commands. + if (tool.getRelativeDirPath().endsWith(join("facets", "instructions"))) { + skillStems.set(stem, dir.getDirName()); + } + } catch (error) { + logger.debug( + `TAKT collision pre-pass: skipping skill "${dir.getDirName()}" due to conversion error: ${formatError(error)}`, + ); + continue; + } + } + + const collisions = new Set(); + for (const [stem, commandSource] of commandStems) { + const skillSource = skillStems.get(stem); + if (skillSource === undefined) continue; + const targetPath = join(".takt", "facets", "instructions", `${stem}.md`); + logger.warn( + `TAKT collision: command "${commandSource}" and skill "${skillSource}" both target ` + + `"${targetPath}". Skipping both files. Rename one source via "takt.name" to disambiguate.`, + ); + collisions.add(stem); + } + + return collisions; +} + +/** + * Filter out colliding TAKT command files (run for the takt target inside + * `generateCommandsCore`). + */ +async function processTaktCommandsWithCollisionFilter(params: { + config: Config; + processor: CommandsProcessor; + rulesyncFiles: RulesyncFile[]; + collisionStems: Set; +}): Promise { + const { config, processor, rulesyncFiles, collisionStems } = params; + if (rulesyncFiles.length === 0) { + if (config.getDelete()) { + const existingToolFiles = await processor.loadToolFiles({ forDeletion: true }); + const orphanCount = await processor.removeOrphanAiFiles(existingToolFiles, []); + return { count: 0, paths: [], hasDiff: orphanCount > 0 }; + } + return { count: 0, paths: [], hasDiff: false }; + } + const allToolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + const filteredToolFiles = + collisionStems.size === 0 + ? allToolFiles + : allToolFiles.filter((file) => { + const fileName = file.getRelativeFilePath(); + const stem = fileName.replace(/\.md$/u, ""); + return !collisionStems.has(stem); + }); + + let totalCount = 0; + const allPaths: string[] = []; + let hasDiff = false; + + const writeResult = await processor.writeAiFiles(filteredToolFiles); + totalCount += writeResult.count; + allPaths.push(...writeResult.paths); + if (writeResult.count > 0) hasDiff = true; + + if (config.getDelete()) { + const existingToolFiles = await processor.loadToolFiles({ forDeletion: true }); + // Use the FILTERED list for the orphan diff so that the previously-written + // colliding file is treated as orphan and removed (rather than retained). + const orphanCount = await processor.removeOrphanAiFiles(existingToolFiles, filteredToolFiles); + if (orphanCount > 0) hasDiff = true; + } + + return { count: totalCount, paths: allPaths, hasDiff }; +} + +/** + * Filter out colliding TAKT skill dirs. The collision warning is logged once + * up-front from `computeTaktInstructionsCollisions`; this helper only filters. + */ +function filterTaktSkillsCollisions(params: { + toolDirs: AiDir[]; + collisionStems: Set; +}): AiDir[] { + const { toolDirs, collisionStems } = params; + if (collisionStems.size === 0) return toolDirs; + return toolDirs.filter((dir) => { + if (!(dir instanceof TaktSkill)) return true; + const stem = dir.getFileName().replace(/\.md$/u, ""); + // Only filter when the skill targets the shared `instructions` dir; other + // facets (knowledge / output-contracts) cannot collide with commands. + if (!dir.getRelativeDirPath().endsWith(join("facets", "instructions"))) { + return true; + } + return !collisionStems.has(stem); + }); +} diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index 3a4f0c13c..bd9a1cb28 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -36,6 +36,7 @@ describe("tool targets", () => { "replit", "roo", "rovodev", + "takt", "warp", "windsurf", "zed", diff --git a/src/types/tool-targets.ts b/src/types/tool-targets.ts index 3887bcdef..94f91799c 100644 --- a/src/types/tool-targets.ts +++ b/src/types/tool-targets.ts @@ -28,6 +28,7 @@ export const ALL_TOOL_TARGETS = [ "replit", "roo", "rovodev", + "takt", "warp", "windsurf", "zed",