From 01f6bcdaee7bec4ebb6a1e59a6332dc5dd8340aa Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Tue, 9 Jun 2026 23:13:30 -0700 Subject: [PATCH 1/2] feat(takt): map the output-contracts facet via takt.facet override Takt has five facet categories but rulesync mapped only four; the output-contracts facet (output structure / report templates) had no rulesync surface. output-contracts does not correspond to a generic rulesync feature, so add a Takt-specific opt-in: a takt.facet override on the rules feature. - A rule with takt: { facet: output-contracts } is emitted to .takt/facets/output-contracts/ instead of the default .takt/facets/policies/ - facet is restricted to policies | output-contracts (the writable non-persona facets that overlap with no other feature); instructions/personas/knowledge stay owned by commands/subagents/skills - Composes with takt.name and takt.extends (output-contracts supports {extends:...} inheritance per Takt docs) - Like takt.name/takt.extends, facet is a generate-side authoring control: Takt facet files carry no frontmatter, so import does not reconstruct it (consistent with Takt's existing import-unsupported design) - Unit tests, an e2e generate case, and docs (file-formats.md, tools/takt.md) Closes #1786 Co-Authored-By: Claude Fable 5 --- docs/reference/file-formats.md | 1 + docs/tools/takt.md | 34 +++++++++-------- skills/rulesync/file-formats.md | 1 + src/e2e/e2e-takt.spec.ts | 26 +++++++++++++ src/features/rules/rulesync-rule.ts | 5 +++ src/features/rules/takt-rule.test.ts | 57 ++++++++++++++++++++++++++++ src/features/rules/takt-rule.ts | 41 ++++++++++++++++---- 7 files changed, 142 insertions(+), 23 deletions(-) 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..386ca473c 100644 --- a/src/features/rules/takt-rule.test.ts +++ b/src/features/rules/takt-rule.test.ts @@ -123,6 +123,63 @@ 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" } } as Record), + }, + 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" } } as Record), + }, + 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" }, + } as Record), + }, + 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..21b80fd9e 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,17 @@ 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); importing `.takt/facets/policies/` yields plain rules. */ export class TaktRule extends ToolRule { static getSettablePaths({ @@ -130,7 +154,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, From 7989edb986dd0368f9177a66a06eafbd5f32b260 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Tue, 9 Jun 2026 23:18:08 -0700 Subject: [PATCH 2/2] docs/test: clarify output-contracts import scope and drop test casts Address low-severity review findings on PR #1812: - Expand the TaktRule JSDoc to state explicitly that import only scans the default policies facet and files under output-contracts/ are not imported - Remove the unnecessary Record casts in the new facet tests now that takt.facet is a typed frontmatter field Co-Authored-By: Claude Fable 5 --- src/features/rules/takt-rule.test.ts | 8 +++----- src/features/rules/takt-rule.ts | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/features/rules/takt-rule.test.ts b/src/features/rules/takt-rule.test.ts index 386ca473c..a88cc80ff 100644 --- a/src/features/rules/takt-rule.test.ts +++ b/src/features/rules/takt-rule.test.ts @@ -131,7 +131,7 @@ describe("TaktRule", () => { relativeFilePath: "review-format.md", frontmatter: { targets: ["*"], - ...({ takt: { facet: "output-contracts" } } as Record), + takt: { facet: "output-contracts" }, }, body: "# Review format", }); @@ -149,7 +149,7 @@ describe("TaktRule", () => { relativeFilePath: "style.md", frontmatter: { targets: ["*"], - ...({ takt: { facet: "policies" } } as Record), + takt: { facet: "policies" }, }, body: "x", }); @@ -168,9 +168,7 @@ describe("TaktRule", () => { relativeFilePath: "source.md", frontmatter: { targets: ["*"], - ...({ - takt: { facet: "output-contracts", name: "report", extends: "base" }, - } as Record), + takt: { facet: "output-contracts", name: "report", extends: "base" }, }, body: "# Report contract", }); diff --git a/src/features/rules/takt-rule.ts b/src/features/rules/takt-rule.ts index 21b80fd9e..01599e24e 100644 --- a/src/features/rules/takt-rule.ts +++ b/src/features/rules/takt-rule.ts @@ -61,7 +61,8 @@ export type TaktRuleParams = Omit & { * 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); importing `.takt/facets/policies/` yields plain rules. + * 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({