diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index a99caea68..dc4da0e93 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -41,6 +41,7 @@ augmentcode: # augmentcode specific parameters 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 "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) + facet: "output-contracts" # (optional) "policies" (default) or "output-contracts": redirect this rule to Takt's output-structure/report-template facet --- # Rulesync Project Overview diff --git a/docs/tools/takt.md b/docs/tools/takt.md index 494e6aeef..0623f1255 100644 --- a/docs/tools/takt.md +++ b/docs/tools/takt.md @@ -4,34 +4,38 @@ ## Output mapping -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. +Each rulesync feature maps onto a dedicated Takt facet directory. The target directory is fixed per feature, except that **rules** may opt into Takt's fifth facet — `output-contracts` — via the `takt.facet` override (see below). -| Rulesync feature | Takt facet directory | -| ---------------- | ---------------------------- | -| `rules` | `.takt/facets/policies/` | -| `commands` | `.takt/facets/instructions/` | -| `subagents` | `.takt/facets/personas/` | -| `skills` | `.takt/facets/knowledge/` | +| Rulesync feature | Takt facet directory | +| ---------------- | --------------------------------------------------------------------------------------- | +| `rules` | `.takt/facets/policies/` (default) or `.takt/facets/output-contracts/` via `takt.facet` | +| `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: +Takt-specific frontmatter knobs: ```yaml --- takt: - name: my-renamed-stem + name: my-renamed-stem # rename the emitted filename stem + extends: base # emit a leading {extends:base} facet-inheritance directive + facet: output-contracts # "policies" (default) or "output-contracts" --- ``` -- `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. +- `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. +- `takt.facet` is **optional** and defaults to `policies`. Setting it to `output-contracts` redirects the rule to Takt's output-structure / report-template facet, which has no dedicated rulesync feature. Both `policies` and `output-contracts` support `{extends:...}` inheritance. The other facets (`instructions`, `personas`, `knowledge`) are owned by the commands, subagents, and skills features and are not selectable via `takt.facet`. +- Like `takt.name` and `takt.extends`, `takt.facet` is a generate-side authoring control. Because Takt facet files are plain Markdown with no frontmatter, the facet selection cannot be recovered on import (see [Importing](#importing-existing-takt-files-into-rulesync) below). Output files are **plain Markdown** — the source frontmatter is dropped entirely and the body is written verbatim: ``` -.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 +.rulesync/rules/style.md → .takt/facets/policies/style.md +.rulesync/rules/review-format.md → .takt/facets/output-contracts/review-format.md (with takt.facet: output-contracts) +.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 diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index a99caea68..dc4da0e93 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -41,6 +41,7 @@ augmentcode: # augmentcode specific parameters 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 "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) + facet: "output-contracts" # (optional) "policies" (default) or "output-contracts": redirect this rule to Takt's output-structure/report-template facet --- # Rulesync Project Overview diff --git a/src/e2e/e2e-takt.spec.ts b/src/e2e/e2e-takt.spec.ts index be69c5677..a79c43416 100644 --- a/src/e2e/e2e-takt.spec.ts +++ b/src/e2e/e2e-takt.spec.ts @@ -111,4 +111,30 @@ Skill body for review. await readFileContent(join(testDir, ".takt", "facets", "knowledge", "review.md")), ).toContain("Skill body for review."); }); + + it("redirects a rule to the output-contracts facet via takt.facet", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "review-format.md"), + `--- +targets: ["*"] +takt: + facet: output-contracts +--- +Review output contract body. +`, + ); + + await runGenerate({ + target: "takt", + features: "rules", + }); + + expect( + await readFileContent( + join(testDir, ".takt", "facets", "output-contracts", "review-format.md"), + ), + ).toContain("Review output contract body."); + }); }); diff --git a/src/features/rules/rulesync-rule.ts b/src/features/rules/rulesync-rule.ts index b8a561f11..dfd98512d 100644 --- a/src/features/rules/rulesync-rule.ts +++ b/src/features/rules/rulesync-rule.ts @@ -78,6 +78,11 @@ export const RulesyncRuleFrontmatterSchema = z.object({ // Facet inheritance: emit a leading `{extends:}` directive (Takt 0.39.0+). // Rules map to the `policies` facet, which supports inheritance. extends: z.optional(z.string()), + // Redirect the rule to a different writable Takt facet. Rules default to the + // `policies` facet; set `facet: "output-contracts"` to author an output-contract + // facet (output structure / report templates) instead. Both facets support + // `{extends:...}` inheritance. See docs/reference/file-formats.md. + facet: z.optional(z.enum(["policies", "output-contracts"])), }), ), }); diff --git a/src/features/rules/takt-rule.test.ts b/src/features/rules/takt-rule.test.ts index 90e95b0ce..a88cc80ff 100644 --- a/src/features/rules/takt-rule.test.ts +++ b/src/features/rules/takt-rule.test.ts @@ -123,6 +123,61 @@ describe("TaktRule", () => { /Invalid takt\.extends/, ); }); + + it("redirects the rule to the output-contracts facet when takt.facet is set", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "review-format.md", + frontmatter: { + targets: ["*"], + takt: { facet: "output-contracts" }, + }, + body: "# Review format", + }); + + const rule = TaktRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + expect(rule.getRelativeDirPath()).toBe(join(".takt", "facets", "output-contracts")); + expect(rule.getRelativeFilePath()).toBe("review-format.md"); + expect(rule.getFileContent()).toBe("# Review format"); + }); + + it("defaults to the policies facet when takt.facet is policies or absent", () => { + const explicit = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "style.md", + frontmatter: { + targets: ["*"], + takt: { facet: "policies" }, + }, + body: "x", + }); + expect( + TaktRule.fromRulesyncRule({ + outputRoot: testDir, + rulesyncRule: explicit, + }).getRelativeDirPath(), + ).toBe(join(".takt", "facets", "policies")); + }); + + it("composes takt.facet with takt.name and takt.extends", () => { + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH, + relativeFilePath: "source.md", + frontmatter: { + targets: ["*"], + takt: { facet: "output-contracts", name: "report", extends: "base" }, + }, + body: "# Report contract", + }); + + const rule = TaktRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + expect(rule.getRelativeDirPath()).toBe(join(".takt", "facets", "output-contracts")); + expect(rule.getRelativeFilePath()).toBe("report.md"); + expect(rule.getFileContent()).toBe("{extends:base}\n\n# Report contract"); + }); }); describe("fromFile", () => { diff --git a/src/features/rules/takt-rule.ts b/src/features/rules/takt-rule.ts index ec5dbc174..01599e24e 100644 --- a/src/features/rules/takt-rule.ts +++ b/src/features/rules/takt-rule.ts @@ -17,13 +17,32 @@ import { } from "./tool-rule.js"; /** - * Fixed facet directory for TAKT rule files. + * Default facet directory for TAKT rule files. * - * Rulesync rules map one-to-one to TAKT's `policies/` facet. No override - * is supported; the directory is always `.takt/facets/policies/`. + * Rulesync rules map to TAKT's `policies/` facet by default. The source + * frontmatter may redirect a rule to another writable facet via `takt.facet` + * (currently `policies` or `output-contracts`); see {@link TAKT_RULE_FACETS}. */ export const DEFAULT_TAKT_RULE_DIR = "policies"; +/** + * Writable Takt facets a rulesync rule may target via `takt.facet`. + * + * `policies` is the default. `output-contracts` lets users author Takt's + * output-structure / report-template facet (which has no dedicated rulesync + * feature) through the rules feature. Both are plain-Markdown facets that + * support `{extends:...}` inheritance. Other facets (`personas`, + * `instructions`, `knowledge`) are owned by the subagents, commands, and skills + * features respectively and are intentionally not selectable here. + */ +export const TAKT_RULE_FACETS = ["policies", "output-contracts"] as const; + +export type TaktRuleFacet = (typeof TAKT_RULE_FACETS)[number]; + +function resolveTaktRuleFacet(facet: unknown): TaktRuleFacet { + return facet === "output-contracts" ? "output-contracts" : DEFAULT_TAKT_RULE_DIR; +} + export type TaktRuleParams = Omit & { body: string; }; @@ -32,12 +51,18 @@ export type TaktRuleParams = Omit & { * Rule generator for TAKT (https://github.com/nrslib/takt). * * TAKT organizes prompts into faceted directories under `.takt/facets/`. - * 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. + * Rulesync rules map to TAKT's `policies/` facet by default; the source + * frontmatter may rename the emitted stem via `takt.name` and redirect the rule + * to another writable facet via `takt.facet` (`policies` or `output-contracts`). + * `output-contracts` lets users author Takt's output-structure / report-template + * facet — which has no dedicated rulesync feature — through the rules feature. * * The emitted files are plain Markdown — frontmatter is always dropped, and - * the body is written verbatim. + * the body is written verbatim. Like `takt.name` and `takt.extends`, the + * `takt.facet` selection is a generate-side authoring control and is not + * reconstructed on import (Takt facet files carry no frontmatter to recover it + * from). Import only scans the default `.takt/facets/policies/` facet and yields + * plain rules; files under `.takt/facets/output-contracts/` are not imported. */ export class TaktRule extends ToolRule { static getSettablePaths({ @@ -130,7 +155,8 @@ export class TaktRule extends ToolRule { assertSafeTaktName({ name: stem, featureLabel: "rule", sourceLabel }); const relativeFilePath = `${stem}.md`; - const relativeDirPath = join(".takt", "facets", DEFAULT_TAKT_RULE_DIR); + const facet = resolveTaktRuleFacet(taktSection?.facet); + const relativeDirPath = join(".takt", "facets", facet); const body = prependTaktExtends({ extendsName: typeof taktSection?.extends === "string" ? taktSection.extends : undefined,