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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down
36 changes: 16 additions & 20 deletions docs/tools/takt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 4 additions & 6 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down Expand Up @@ -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 "..")
---

Expand Down
7 changes: 3 additions & 4 deletions src/cli/commands/gitignore-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const collisions: string[] = [];
for (const tag of GITIGNORE_ENTRY_REGISTRY) {
Expand Down
16 changes: 2 additions & 14 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,21 +224,11 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ 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/" },
Expand All @@ -255,9 +245,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [

export const ALL_GITIGNORE_ENTRIES: ReadonlyArray<string> = (() => {
// 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<string>();
const result: string[] = [];
for (const tag of GITIGNORE_ENTRY_REGISTRY) {
Expand Down
4 changes: 2 additions & 2 deletions src/e2e/e2e-skills.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
88 changes: 40 additions & 48 deletions src/e2e/e2e-takt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.");
});
});
1 change: 0 additions & 1 deletion src/features/commands/rulesync-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}),
),
Expand Down
Loading
Loading