diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index f995b807..31a88622 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -25,8 +25,7 @@ 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" +takt: # takt specific parameters (optional; emitted under .takt/facets/policies/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -186,7 +185,7 @@ 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") +takt: # takt specific parameters (optional; emitted under .takt/facets/instructions/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -227,7 +226,7 @@ opencode: # for OpenCode-specific parameters permission: bash: "git diff": allow -takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "persona") +takt: # takt specific parameters (optional; emitted under .takt/facets/personas/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -260,8 +259,7 @@ 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" +takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- diff --git a/docs/tools/takt.md b/docs/tools/takt.md index 4cfaf1d9..b65f0604 100644 --- a/docs/tools/takt.md +++ b/docs/tools/takt.md @@ -4,44 +4,40 @@ ## 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` | +Each rulesync feature maps one-to-one onto a dedicated Takt facet directory. There is **no `takt.facet` override** — the target directory is fixed per feature. -The facet override is read from the rulesync source frontmatter under the `takt:` key: +| Rulesync feature | Takt facet directory | +| ---------------- | ---------------------------- | +| `rules` | `.takt/facets/policies/` | +| `commands` | `.takt/facets/instructions/` | +| `subagents` | `.takt/facets/personas/` | +| `skills` | `.takt/facets/knowledge/` | + +The only Takt-specific frontmatter knob is `takt.name`, which renames the emitted filename stem: ```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. +- `takt.name` is **optional**; the source filename stem is used by default. +- Unsafe values (path separators, `..` segments, etc.) raise a hard validation error at `generate` time. -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: +Output files are **plain Markdown** — the source frontmatter is dropped entirely and the body is written verbatim: ``` -.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 +.rulesync/rules/style.md → .takt/facets/policies/style.md +.rulesync/commands/review.md → .takt/facets/instructions/review.md +.rulesync/subagents/coder.md → .takt/facets/personas/coder.md +.rulesync/skills/oncall/SKILL.md → .takt/facets/knowledge/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 f995b807..31a88622 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -25,8 +25,7 @@ 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" +takt: # takt specific parameters (optional; emitted under .takt/facets/policies/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -186,7 +185,7 @@ 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") +takt: # takt specific parameters (optional; emitted under .takt/facets/instructions/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -227,7 +226,7 @@ opencode: # for OpenCode-specific parameters permission: bash: "git diff": allow -takt: # takt specific parameters (optional; only `name` is honored — `facet` is fixed to "persona") +takt: # takt specific parameters (optional; emitted under .takt/facets/personas/) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- @@ -260,8 +259,7 @@ 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" +takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") --- diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index bdca599d..605011b5 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -24,10 +24,9 @@ const TARGETS_WITHOUT_GITIGNORE_ENTRIES = new Set([ describe("GITIGNORE_ENTRY_REGISTRY", () => { 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. + // different feature tags. 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) { diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index eff1325a..ff5b5450 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -224,21 +224,11 @@ 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. + // Each rulesync feature maps one-to-one onto a TAKT facet directory. { 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/" }, @@ -255,9 +245,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ 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. + // The exported list dedupes while preserving the original insertion order. const seen = new Set(); const result: string[] = []; for (const tag of GITIGNORE_ENTRY_REGISTRY) { diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index 21b8e935..6a994867 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -81,7 +81,7 @@ describe("E2E: skills", () => { }, { target: "takt", - outputPath: join(".takt", "facets", "instructions", "test-skill.md"), + outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), }, ])("should generate $target skills", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -271,7 +271,7 @@ describe("E2E: skills (global mode)", () => { }, { target: "takt", - outputPath: join(".takt", "facets", "instructions", "test-skill.md"), + outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), }, ])("should generate $target skills in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-takt.spec.ts b/src/e2e/e2e-takt.spec.ts index 31dcd789..be69c567 100644 --- a/src/e2e/e2e-takt.spec.ts +++ b/src/e2e/e2e-takt.spec.ts @@ -4,47 +4,44 @@ import { describe, expect, it } from "vitest"; import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_SKILLS_RELATIVE_DIR_PATH, + RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; -import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; +import { readFileContent, writeFileContent } from "../utils/file.js"; import { runGenerate, useTestDirectory } from "./e2e-helper.js"; -describe("E2E: takt instructions-facet collisions", () => { +describe("E2E: takt Tool x Feature matrix (1:1 facet mapping)", () => { const { getTestDir } = useTestDirectory(); - it("skips both colliding command and skill, keeps non-colliding files, logs a warning", async () => { + it("generates rules, commands, subagents, and skills into their dedicated facet dirs", 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"), + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "style.md"), `--- -description: "Review" targets: ["*"] --- -Command body for review. +Rule body for style. `, ); await writeFileContent( - join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "ship.md"), + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review.md"), `--- -description: "Ship" +description: "Review" targets: ["*"] --- -Command body for ship. +Command body for review. `, ); await writeFileContent( - join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "review", "SKILL.md"), + join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), `--- -name: review -description: "Review skill" targets: ["*"] +name: planner +description: "plans" --- -Skill body for review. +Subagent body for planner. `, ); await writeFileContent( @@ -58,65 +55,60 @@ Skill body for runbook. `, ); - const { stderr } = await runGenerate({ + 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" }, + features: "rules,commands,subagents,skills", }); - // 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."); + expect( + await readFileContent(join(testDir, ".takt", "facets", "policies", "style.md")), + ).toContain("Rule body for style."); + expect( + await readFileContent(join(testDir, ".takt", "facets", "instructions", "review.md")), + ).toContain("Command body for review."); + expect( + await readFileContent(join(testDir, ".takt", "facets", "personas", "planner.md")), + ).toContain("Subagent body for planner."); + expect( + await readFileContent(join(testDir, ".takt", "facets", "knowledge", "runbook.md")), + ).toContain("Skill body for runbook."); }); - it("generates both files when stems do not collide (no warning)", async () => { + it("generates commands and skills into separate facet dirs with shared stems (no collision)", async () => { const testDir = getTestDir(); + // Both share the stem `review` but target different facet directories now: + // commands → instructions/, skills → knowledge/ await writeFileContent( join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review.md"), `--- description: "Review" targets: ["*"] --- -Command body. +Command body for review. `, ); await writeFileContent( - join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "runbook", "SKILL.md"), + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "review", "SKILL.md"), `--- -name: runbook -description: "Runbook" +name: review +description: "Review skill" targets: ["*"] --- -Skill body. +Skill body for review. `, ); - const { stderr } = await runGenerate({ + 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."); + ).toContain("Command body for review."); expect( - await readFileContent(join(testDir, ".takt", "facets", "instructions", "runbook.md")), - ).toContain("Skill body."); + await readFileContent(join(testDir, ".takt", "facets", "knowledge", "review.md")), + ).toContain("Skill body for review."); }); }); diff --git a/src/features/commands/rulesync-command.ts b/src/features/commands/rulesync-command.ts index 19dfe2b6..6686e026 100644 --- a/src/features/commands/rulesync-command.ts +++ b/src/features/commands/rulesync-command.ts @@ -21,7 +21,6 @@ export const RulesyncCommandFrontmatterSchema = z.looseObject({ description: z.optional(z.string()), takt: z.optional( z.looseObject({ - facet: z.optional(z.string()), name: z.optional(z.string()), }), ), diff --git a/src/features/commands/takt-command.test.ts b/src/features/commands/takt-command.test.ts index fd83c384..43488ecf 100644 --- a/src/features/commands/takt-command.test.ts +++ b/src/features/commands/takt-command.test.ts @@ -6,7 +6,7 @@ import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-pa import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { RulesyncCommand } from "./rulesync-command.js"; -import { resolveTaktCommandFacetDir, TaktCommand } from "./takt-command.js"; +import { TaktCommand } from "./takt-command.js"; describe("TaktCommand", () => { let testDir: string; @@ -30,18 +30,6 @@ describe("TaktCommand", () => { }); }); - 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({ @@ -73,6 +61,7 @@ describe("TaktCommand", () => { }); const cmd = TaktCommand.fromRulesyncCommand({ baseDir: testDir, rulesyncCommand }); expect(cmd.getRelativeFilePath()).toBe("short.md"); + expect(cmd.getRelativeDirPath()).toBe(join(".takt", "facets", "instructions")); }); it("throws on an unsafe takt.name value", () => { @@ -91,23 +80,6 @@ describe("TaktCommand", () => { /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", () => { diff --git a/src/features/commands/takt-command.ts b/src/features/commands/takt-command.ts index 56802613..28cf2134 100644 --- a/src/features/commands/takt-command.ts +++ b/src/features/commands/takt-command.ts @@ -3,7 +3,7 @@ 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 { assertSafeTaktName } from "../takt-shared.js"; import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; import { ToolCommand, @@ -13,36 +13,8 @@ import { 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; @@ -52,7 +24,8 @@ export type TaktCommandParams = { * * 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`. + * filename stem is preserved unless overridden via `takt.name`. The facet + * directory is fixed — no `takt.facet` override is supported. */ export class TaktCommand extends ToolCommand { private readonly body: string; @@ -104,9 +77,6 @@ export class TaktCommand extends ToolCommand { 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; diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index 4e500da7..e9e1f3d3 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -56,9 +56,6 @@ export const RulesyncRuleFrontmatterSchema = z.object({ ), 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()), }), diff --git a/src/features/rules/takt-rule.test.ts b/src/features/rules/takt-rule.test.ts index be03ae92..38240efc 100644 --- a/src/features/rules/takt-rule.test.ts +++ b/src/features/rules/takt-rule.test.ts @@ -9,12 +9,7 @@ import { 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"; +import { DEFAULT_TAKT_RULE_DIR, TaktRule } from "./takt-rule.js"; describe("TaktRule", () => { let testDir: string; @@ -31,11 +26,12 @@ describe("TaktRule", () => { }); describe("getSettablePaths", () => { - it("returns a non-root facet path in project mode", () => { + it("returns the fixed policies facet path in project mode", () => { const paths = TaktRule.getSettablePaths(); expect("nonRoot" in paths && paths.nonRoot?.relativeDirPath).toBe( join(".takt", "facets", DEFAULT_TAKT_RULE_DIR), ); + expect(DEFAULT_TAKT_RULE_DIR).toBe("policies"); }); it("returns a root path in global mode", () => { @@ -46,26 +42,8 @@ describe("TaktRule", () => { }); }); - 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", () => { + it("emits a plain Markdown body under policies/", () => { const rulesyncRule = new RulesyncRule({ baseDir: testDir, relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, @@ -78,29 +56,9 @@ describe("TaktRule", () => { 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, @@ -114,6 +72,7 @@ describe("TaktRule", () => { }); const rule = TaktRule.fromRulesyncRule({ baseDir: testDir, rulesyncRule }); + expect(rule.getRelativeDirPath()).toBe(join(".takt", "facets", "policies")); expect(rule.getRelativeFilePath()).toBe("short.md"); }); @@ -132,23 +91,6 @@ describe("TaktRule", () => { /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", () => { diff --git a/src/features/rules/takt-rule.ts b/src/features/rules/takt-rule.ts index f8ee5bf9..9ec4aa55 100644 --- a/src/features/rules/takt-rule.ts +++ b/src/features/rules/takt-rule.ts @@ -3,7 +3,7 @@ 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 { assertSafeTaktName } from "../takt-shared.js"; import { RulesyncRule } from "./rulesync-rule.js"; import { ToolRule, @@ -17,53 +17,24 @@ import { } from "./tool-rule.js"; /** - * Allowed `facet` values for TAKT rule files. + * Fixed facet directory for TAKT rule files. * - * - `policy`: hard rules and constraints (default) - * - `knowledge`: factual context (architecture, glossaries, references) - * - `output-contract`: output formatting / contract specifications + * Rulesync rules map one-to-one to TAKT's `policies/` facet. No override + * is supported; the directory is always `.takt/facets/policies/`. */ -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 const DEFAULT_TAKT_RULE_DIR = "policies"; 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`. + * Rulesync rules always map to TAKT's `policies/` facet; the source + * frontmatter may rename the emitted stem via `takt.name`, but the facet + * directory is fixed. * * The emitted files are plain Markdown — frontmatter is always dropped, and * the body is written verbatim. @@ -153,15 +124,13 @@ export class TaktRule extends ToolRule { 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); + const relativeDirPath = join(".takt", "facets", DEFAULT_TAKT_RULE_DIR); return new TaktRule({ baseDir, diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 6c22fa80..dd52ab6c 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -50,9 +50,6 @@ const RulesyncSkillFrontmatterSchemaInternal = 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()), }), @@ -90,7 +87,6 @@ export type RulesyncSkillFrontmatterInput = { roo?: Record; cline?: Record; takt?: { - facet?: string; name?: string; }; }; diff --git a/src/features/skills/takt-skill.test.ts b/src/features/skills/takt-skill.test.ts index 0d43b182..c38ca24e 100644 --- a/src/features/skills/takt-skill.test.ts +++ b/src/features/skills/takt-skill.test.ts @@ -4,12 +4,7 @@ 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"; +import { DEFAULT_TAKT_SKILL_DIR, TaktSkill } from "./takt-skill.js"; describe("TaktSkill", () => { let testDir: string; @@ -26,29 +21,16 @@ describe("TaktSkill", () => { }); describe("getSettablePaths", () => { - it("defaults to the instructions facet directory", () => { + it("defaults to the knowledge 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/); + expect(DEFAULT_TAKT_SKILL_DIR).toBe("knowledge"); }); }); describe("fromRulesyncSkill", () => { - it("emits a single flat .md file under the default facet", () => { + it("emits a single flat .md file under knowledge/", () => { const rulesyncSkill = new RulesyncSkill({ baseDir: testDir, dirName: "runbook", @@ -61,29 +43,12 @@ describe("TaktSkill", () => { }); const skill = TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill }); - expect(skill.getRelativeDirPath()).toBe(join(".takt", "facets", "instructions")); + expect(skill.getRelativeDirPath()).toBe(join(".takt", "facets", "knowledge")); 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")); + expect(skill.getDirPath()).toBe(join(testDir, ".takt", "facets", "knowledge")); }); it("renames the stem with takt.name", () => { @@ -101,6 +66,7 @@ describe("TaktSkill", () => { const skill = TaktSkill.fromRulesyncSkill({ baseDir: testDir, rulesyncSkill }); expect(skill.getFileName()).toBe("short.md"); + expect(skill.getRelativeDirPath()).toBe(join(".takt", "facets", "knowledge")); }); it("throws on an unsafe takt.name value", () => { @@ -119,23 +85,6 @@ describe("TaktSkill", () => { /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", () => { @@ -178,7 +127,7 @@ describe("TaktSkill", () => { it("toRulesyncSkill throws because reverse import is unsupported", () => { const skill = new TaktSkill({ baseDir: testDir, - relativeDirPath: join(".takt", "facets", "instructions"), + relativeDirPath: join(".takt", "facets", "knowledge"), dirName: "x", fileName: "x.md", body: "y", @@ -191,7 +140,7 @@ describe("TaktSkill", () => { it("constructs an empty instance for deletion", () => { const skill = TaktSkill.forDeletion({ baseDir: testDir, - relativeDirPath: join(".takt", "facets", "instructions"), + relativeDirPath: join(".takt", "facets", "knowledge"), dirName: "x", }); expect(skill.getBody()).toBe(""); diff --git a/src/features/skills/takt-skill.ts b/src/features/skills/takt-skill.ts index f2125740..d7a73688 100644 --- a/src/features/skills/takt-skill.ts +++ b/src/features/skills/takt-skill.ts @@ -2,7 +2,7 @@ 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 { assertSafeTaktName } from "../takt-shared.js"; import { RulesyncSkill, SkillFile } from "./rulesync-skill.js"; import { ToolSkill, @@ -13,41 +13,12 @@ import { } from "./tool-skill.js"; /** - * Allowed `facet` values for TAKT skill files. + * Fixed facet directory for TAKT skill files. * - * - `instruction`: behavioral instructions (default) - * - `knowledge`: factual context - * - `output-contract`: output formatting / contract specifications + * Rulesync skills map one-to-one to TAKT's `knowledge/` facet. No override + * is supported; the directory is always `.takt/facets/knowledge/`. */ -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 const DEFAULT_TAKT_SKILL_DIR = "knowledge"; export type TaktSkillParams = { baseDir?: string; @@ -65,15 +36,15 @@ export type TaktSkillParams = { * 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/`. + * (one `.md` per skill) under `.takt/facets/knowledge/`. The facet directory + * is fixed — no `takt.facet` override is supported. * * 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` + * `.takt/facets/knowledge/{stem}.md` * * The original frontmatter is dropped — only the body is written verbatim. */ @@ -161,9 +132,9 @@ export class TaktSkill extends ToolSkill { 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. + // `RulesyncSkill` cannot recover the original `description` 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.", @@ -180,14 +151,12 @@ export class TaktSkill extends ToolSkill { 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); + const relativeDirPath = join(".takt", "facets", DEFAULT_TAKT_SKILL_DIR); return new TaktSkill({ baseDir, diff --git a/src/features/subagents/rulesync-subagent.ts b/src/features/subagents/rulesync-subagent.ts index 2394c9ae..d8921d6e 100644 --- a/src/features/subagents/rulesync-subagent.ts +++ b/src/features/subagents/rulesync-subagent.ts @@ -22,7 +22,6 @@ export const RulesyncSubagentFrontmatterSchema = z.looseObject({ description: z.optional(z.string()), takt: z.optional( z.looseObject({ - facet: z.optional(z.string()), name: z.optional(z.string()), }), ), diff --git a/src/features/subagents/takt-subagent.test.ts b/src/features/subagents/takt-subagent.test.ts index 3b639115..2f80cc48 100644 --- a/src/features/subagents/takt-subagent.test.ts +++ b/src/features/subagents/takt-subagent.test.ts @@ -6,7 +6,7 @@ import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-p import { setupTestDirectory } from "../../test-utils/test-directories.js"; import { ensureDir, writeFileContent } from "../../utils/file.js"; import { RulesyncSubagent } from "./rulesync-subagent.js"; -import { resolveTaktSubagentFacetDir, TaktSubagent } from "./takt-subagent.js"; +import { TaktSubagent } from "./takt-subagent.js"; describe("TaktSubagent", () => { let testDir: string; @@ -30,18 +30,6 @@ describe("TaktSubagent", () => { }); }); - 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({ @@ -85,6 +73,7 @@ describe("TaktSubagent", () => { rulesyncSubagent, }); expect(sub.getRelativeFilePath()).toBe("short.md"); + expect(sub.getRelativeDirPath()).toBe(join(".takt", "facets", "personas")); }); it("throws on an unsafe takt.name value", () => { @@ -107,27 +96,6 @@ describe("TaktSubagent", () => { }), ).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", () => { diff --git a/src/features/subagents/takt-subagent.ts b/src/features/subagents/takt-subagent.ts index f1e8e5cf..b730fa02 100644 --- a/src/features/subagents/takt-subagent.ts +++ b/src/features/subagents/takt-subagent.ts @@ -4,7 +4,7 @@ import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-p 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 { assertSafeTaktName } from "../takt-shared.js"; import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; import { ToolSubagent, @@ -14,36 +14,8 @@ import { 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; @@ -53,7 +25,8 @@ export type TaktSubagentParams = { * * 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`. + * filename stem is preserved unless overridden via `takt.name`. The facet + * directory is fixed — no `takt.facet` override is supported. */ export class TaktSubagent extends ToolSubagent { private readonly body: string; @@ -101,9 +74,6 @@ export class TaktSubagent extends ToolSubagent { 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; diff --git a/src/features/takt-shared.test.ts b/src/features/takt-shared.test.ts index a963bb65..1a61033a 100644 --- a/src/features/takt-shared.test.ts +++ b/src/features/takt-shared.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { assertSafeTaktName, resolveTaktFacetDir } from "./takt-shared.js"; +import { assertSafeTaktName } from "./takt-shared.js"; describe("assertSafeTaktName", () => { it.each([["plain"], ["with-dash"], ["with_underscore"], ["with.dot"], ["mixed-1.2_3"]])( @@ -36,60 +36,3 @@ describe("assertSafeTaktName", () => { ).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 index 26764bb6..46ad1b13 100644 --- a/src/features/takt-shared.ts +++ b/src/features/takt-shared.ts @@ -1,5 +1,3 @@ -import { z } from "zod/mini"; - /** * Shared utilities for all TAKT-* tool file classes. * @@ -46,46 +44,3 @@ export function assertSafeTaktName({ ); } } - -/** - * 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 3e852d87..00a006e5 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -5,8 +5,6 @@ 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"; @@ -14,7 +12,6 @@ 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"; @@ -186,19 +183,9 @@ export async function generate(params: { const ignoreResult = await generateIgnoreCore({ config, logger }); const mcpResult = await generateMcpCore({ 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 commandsResult = await generateCommandsCore({ config, logger }); const subagentsResult = await generateSubagentsCore({ config, logger }); - const skillsResult = await generateSkillsCore({ config, logger, taktInstructionsCollisions }); + const skillsResult = await generateSkillsCore({ config, logger }); 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). @@ -391,9 +378,8 @@ async function generateMcpCore(params: { async function generateCommandsCore(params: { config: Config; logger: Logger; - taktInstructionsCollisions?: Map>; }): Promise { - const { config, logger, taktInstructionsCollisions } = params; + const { config, logger } = params; let totalCount = 0; const allPaths: string[] = []; @@ -429,18 +415,11 @@ async function generateCommandsCore(params: { const rulesyncFiles = await processor.loadRulesyncFiles(); - 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 }); - } + const result = await processFeatureWithRulesyncFiles({ + config, + processor, + rulesyncFiles, + }); totalCount += result.count; allPaths.push(...result.paths); @@ -504,9 +483,8 @@ async function generateSubagentsCore(params: { async function generateSkillsCore(params: { config: Config; logger: Logger; - taktInstructionsCollisions?: Map>; }): Promise { - const { config, logger, taktInstructionsCollisions } = params; + const { config, logger } = params; let totalCount = 0; const allPaths: string[] = []; @@ -549,15 +527,7 @@ async function generateSkillsCore(params: { } } - const allToolDirs = await processor.convertRulesyncDirsToToolDirs(rulesyncDirs); - - const toolDirs = - toolTarget === "takt" && taktInstructionsCollisions - ? filterTaktSkillsCollisions({ - toolDirs: allToolDirs, - collisionStems: taktInstructionsCollisions.get(baseDir) ?? new Set(), - }) - : allToolDirs; + const toolDirs = await processor.convertRulesyncDirsToToolDirs(rulesyncDirs); const result = await processDirFeatureGeneration({ config, @@ -672,216 +642,3 @@ 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); - }); -}